diff --git a/.topology-lab/load-gen.py b/.topology-lab/load-gen.py new file mode 100755 index 000000000000..63a49a23103d --- /dev/null +++ b/.topology-lab/load-gen.py @@ -0,0 +1,266 @@ +#!/usr/bin/env python3 +""" +Topology lab traffic generator. + +Drives time-varying TCP load across all 6 spine-leaf links in BOTH directions +so SNMP interface counters show realistic bidirectional utilization patterns. + +Each link gets two independent TCP flows: + - Forward: spine → leaf (server on leaf, port 52xx) + - Reverse: leaf → spine (server on spine, port 53xx) + +This gives both ifHCInOctets and ifHCOutOctets meaningful values on every +interface, exercising all five weathermap utilization bands. + +Usage: + python3 .topology-lab/load-gen.py [--max-mbps 100] [--interval 30] + +Press Ctrl+C to stop and clean up. +""" + +import argparse +import math +import random +import signal +import subprocess +import sys +import time + +# --------------------------------------------------------------------------- +# Topology +# --------------------------------------------------------------------------- + +CONTAINERS = [ + "topo-spine-01", + "topo-spine-02", + "topo-leaf-01", + "topo-leaf-02", + "topo-leaf-03", +] + +# Forward links: spine sends to leaf. +# (src_container, src_bind_ip, dst_container, dst_bind_ip, fwd_port, link_idx) +FWD_LINKS = [ + ("topo-spine-01", "10.101.1.1", "topo-leaf-01", "10.101.1.2", 5201, 0), + ("topo-spine-01", "10.101.2.1", "topo-leaf-02", "10.101.2.2", 5202, 1), + ("topo-spine-01", "10.101.3.1", "topo-leaf-03", "10.101.3.2", 5203, 2), + ("topo-spine-02", "10.101.4.1", "topo-leaf-01", "10.101.4.2", 5204, 3), + ("topo-spine-02", "10.101.5.1", "topo-leaf-02", "10.101.5.2", 5205, 4), + ("topo-spine-02", "10.101.6.1", "topo-leaf-03", "10.101.6.2", 5206, 5), +] + +# Reverse links: leaf sends to spine. +# (src_container, src_bind_ip, dst_container, dst_bind_ip, rev_port, link_idx) +REV_LINKS = [ + ("topo-leaf-01", "10.101.1.2", "topo-spine-01", "10.101.1.1", 5301, 0), + ("topo-leaf-02", "10.101.2.2", "topo-spine-01", "10.101.2.1", 5302, 1), + ("topo-leaf-03", "10.101.3.2", "topo-spine-01", "10.101.3.1", 5303, 2), + ("topo-leaf-01", "10.101.4.2", "topo-spine-02", "10.101.4.1", 5304, 3), + ("topo-leaf-02", "10.101.5.2", "topo-spine-02", "10.101.5.1", 5305, 4), + ("topo-leaf-03", "10.101.6.2", "topo-spine-02", "10.101.6.1", 5306, 5), +] + +ALL_CONTAINERS = CONTAINERS + +# iperf3 is baked into the topology-node image for all containers. +IPERF3 = {c: "/usr/bin/iperf3" for c in ALL_CONTAINERS} + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def run(cmd: str, check: bool = False) -> subprocess.CompletedProcess: + return subprocess.run(cmd, shell=True, capture_output=True, text=True) + + +def pexec(container: str, sh_cmd: str) -> subprocess.CompletedProcess: + """Run sh_cmd inside container, return result.""" + return run(f"podman exec {container} sh -c {repr(sh_cmd)}") + + +def pexec_bg(container: str, sh_cmd: str) -> None: + """Run sh_cmd inside container in the background (fire-and-forget).""" + run(f"podman exec -d {container} sh -c {repr(sh_cmd)}") + + +# --------------------------------------------------------------------------- +# Bandwidth shape function +# --------------------------------------------------------------------------- + +def target_mbps(link_idx: int, t: float, max_mbps: float) -> float: + """ + Return a target bandwidth in Mbit/s for the given link at time t. + + Combines three overlapping sine waves at different periods and phases + so each link has a distinct, non-repeating-looking pattern, plus + uniform noise and rare saturation spikes. + + - Slow wave : 5-minute period, drives the broad utilization envelope + - Medium wave: 2-minute period, mid-frequency ripple + - Fast wave : 45-second period, short bursts + + Result is clamped to [2%, 98%] of max_mbps. + """ + # Each link gets a unique phase so the 6 links don't all peak together + phi = link_idx * math.pi / 3.0 + + slow = 0.40 * math.sin(2 * math.pi * t / 300.0 + phi) + medium = 0.15 * math.sin(2 * math.pi * t / 120.0 + phi * 1.7) + fast = 0.05 * math.sin(2 * math.pi * t / 45.0 + phi * 2.3) + noise = 0.08 * random.uniform(-1.0, 1.0) + + # Raw utilization fraction: centred around 0.45, range roughly 0.02–0.88 + frac = 0.45 + slow + medium + fast + noise + + # Occasional saturation spike: ~5% chance per link per interval + if random.random() < 0.05: + frac = random.uniform(0.85, 0.97) + + frac = max(0.02, min(0.98, frac)) + return frac * max_mbps + + +# --------------------------------------------------------------------------- +# Setup +# --------------------------------------------------------------------------- + +def install_iperf3() -> None: + print("Checking iperf3 on all containers...") + for c in ALL_CONTAINERS: + bin_path = IPERF3[c] + r = pexec(c, f"{bin_path} --version 2>&1 | head -1") + if r.returncode == 0: + status = r.stdout.strip() or "ok" + else: + r2 = pexec(c, "apk add -q --no-cache iperf3") + status = "installed via apk" if r2.returncode == 0 else f"MISSING — copy binary to {bin_path}" + print(f" {c}: {status}") + + +def stop_iperf3() -> None: + """Kill all iperf3 processes in all containers (servers and clients).""" + for c in ALL_CONTAINERS: + bin_path = IPERF3[c] + pexec(c, f"pkill -f '{bin_path}' 2>/dev/null; pkill -f iperf3 2>/dev/null; true") + + +def start_servers() -> None: + print("Starting iperf3 servers (forward: leaves, reverse: spines)...") + time.sleep(0.5) # let pkill settle + + # Forward servers on leaves + for src, src_ip, dst, dst_ip, port, idx in FWD_LINKS: + bin_path = IPERF3[dst] + cmd = ( + f"nohup {bin_path} -s -B {dst_ip} -p {port} " + f"--logfile /tmp/iperf3-server-{port}.log " + f">/dev/null 2>&1 &" + ) + r = pexec(dst, cmd) + status = "ok" if r.returncode == 0 else f"FAILED: {r.stderr.strip()}" + print(f" fwd-server {dst} {dst_ip}:{port} {status}") + + # Reverse servers on spines + for src, src_ip, dst, dst_ip, port, idx in REV_LINKS: + bin_path = IPERF3[dst] + cmd = ( + f"nohup {bin_path} -s -B {dst_ip} -p {port} " + f"--logfile /tmp/iperf3-server-{port}.log " + f">/dev/null 2>&1 &" + ) + r = pexec(dst, cmd) + status = "ok" if r.returncode == 0 else f"FAILED: {r.stderr.strip()}" + print(f" rev-server {dst} {dst_ip}:{port} {status}") + + time.sleep(0.5) # give servers a moment to bind + + +# --------------------------------------------------------------------------- +# Traffic loop +# --------------------------------------------------------------------------- + +def fire_clients(t: float, interval: int, max_mbps: float) -> None: + # Kill previous client wave first + for c in ALL_CONTAINERS: + bin_path = IPERF3[c] + pexec(c, f"pkill -f '{bin_path} -c' 2>/dev/null; pkill -f 'iperf3 -c' 2>/dev/null; true") + time.sleep(0.3) + + print(f"\n[{time.strftime('%H:%M:%S')}] Link utilization (TCP bidirectional):") + + # Forward flows: spine → leaf + for src, src_ip, dst, dst_ip, port, idx in FWD_LINKS: + bw = target_mbps(idx, t, max_mbps) + bin_path = IPERF3[src] + cmd = ( + f"nohup {bin_path} -c {dst_ip} -p {port} " + f"-b {bw:.2f}M " + f"-t {interval + 5} " + f"-B {src_ip} " + f"--logfile /tmp/iperf3-client-{port}.log " + f">/dev/null 2>&1 &" + ) + pexec(src, cmd) + bar_len = int(bw / max_mbps * 30) + bar = "#" * bar_len + "." * (30 - bar_len) + print(f" fwd {src} → {dst_ip}:{port} [{bar}] {bw:5.1f}/{max_mbps:.0f} Mbit/s") + + # Reverse flows: leaf → spine + for src, src_ip, dst, dst_ip, port, idx in REV_LINKS: + bw = target_mbps(idx, t + 7, max_mbps) # offset phase slightly for asymmetry + bin_path = IPERF3[src] + cmd = ( + f"nohup {bin_path} -c {dst_ip} -p {port} " + f"-b {bw:.2f}M " + f"-t {interval + 5} " + f"-B {src_ip} " + f"--logfile /tmp/iperf3-rclient-{port}.log " + f">/dev/null 2>&1 &" + ) + pexec(src, cmd) + bar_len = int(bw / max_mbps * 30) + bar = "#" * bar_len + "." * (30 - bar_len) + print(f" rev {src} → {dst_ip}:{port} [{bar}] {bw:5.1f}/{max_mbps:.0f} Mbit/s") + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--max-mbps", type=float, default=100.0, + help="Virtual link speed in Mbit/s (default: 100)") + parser.add_argument("--interval", type=int, default=30, + help="Seconds between rate updates (default: 30)") + args = parser.parse_args() + + def cleanup(sig, frame): + print("\n\nShutting down — killing iperf3 in all containers...") + stop_iperf3() + sys.exit(0) + + signal.signal(signal.SIGINT, cleanup) + signal.signal(signal.SIGTERM, cleanup) + + print("=== Topology Lab Traffic Generator ===") + print(f" Links : {len(FWD_LINKS)} forward + {len(REV_LINKS)} reverse = {len(FWD_LINKS)*2} total flows") + print(f" Max speed : {args.max_mbps:.0f} Mbit/s per direction") + print(f" Interval : {args.interval}s") + print(f" Protocol : TCP (reliable bidirectional, no UDP loss)") + print() + + stop_iperf3() + install_iperf3() + start_servers() + + print(f"\nGenerating traffic — Ctrl+C to stop.\n") + + while True: + fire_clients(time.time(), args.interval, args.max_mbps) + time.sleep(args.interval) + + +if __name__ == "__main__": + main() diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000000..8b8e23abc8d7 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,149 @@ +# CLAUDE.md + +## DO THE WORK — NO EXCEPTIONS + +This is the #1 rule. Everything else is secondary. + +- **When you change code, build and deploy it immediately.** Do not describe what the user should do. Do not say "now run X". Run it yourself. The change is not done until it is LIVE in the container. +- **When a service is stopped, start it.** When a container is down, bring it up. When a build is needed, run it. Never hand the user a command to run. +- **Trust the user's observations.** When they say something is broken, fix it — do not waste cycles reproducing what they already told you. +- **Do not ask permission for obvious next steps.** Build, deploy, verify, report. If the user says "fix X", that means fix it, deploy it, verify it works, and tell them to hard-refresh. +- **Do not brainstorm, propose options, or over-explain** when the path is clear. Just do it. +- **Fix things right the first time.** Read memories, project docs, and prior conversation before acting. Do not iterate through 3-4 broken attempts. Each rebuild cycle costs minutes and dollars. + +## HARD RULES + +### Git Hygiene + +1. **NEVER commit to `master`, `develop`, or any upstream branch.** All work goes on `feature/*` or `feat/*` branches. +2. **Push ONLY to `fork` remote** (cnewkirk/opennms). Never push to `origin`. +3. **NEVER open PRs against OpenNMS/opennms** without the user explicitly typing "OpenNMS/opennms" in their message. PRs are opened on `cnewkirk/opennms` (fork-to-fork). Unauthorized upstream PRs are irreversible and cause public embarrassment. +4. **Never add Co-Authored-By Claude or credit Claude on commits/PRs.** Never commit Claude-specific files to the repo. +5. **Squash iterative commits** into logical groups before pushing. Maintainers don't want to see the sausage being made. +6. **Git identity:** `Chance Newkirk ` + +### UI Build & Deploy (for any change under `ui/src/`) + +Every UI change MUST follow this exact sequence — no shortcuts, no skipping steps: + +**Bootstrap (fresh clone only):** `ui/target/node/` does not exist until Maven has run. Bootstrap once with: +```bash +cd ui && mvn install -DskipTests -Prun-npm +``` +This downloads the correct Node and Yarn versions into `ui/target/node/`. + +1. `cd ui && ./target/node/yarn/dist/bin/yarn build` + - **NEVER use `npm run build`** — it writes to `ui/dist/` which the deploy script does NOT read. Silent failure. + - Run from the repo root `ui/` directory. The yarn binary lives in `target/node/` (downloaded by Maven). +2. Verify `ui/src/main/dist/index.html` has `src="/opennms/ui/assets/index-*.js"` paths +3. Verify built CSS has no bare `--feather-*` values (must be wrapped in `var()`) +4. `./ui/deploy-to-container.sh test-opennms` (if container is stopped, START IT FIRST) +5. Verify live bundle hash matches built hash: + - `podman exec test-opennms grep -o 'assets/index-[^"]*\.js' /opt/opennms/jetty-webapps/opennms/ui/index.html` + - `grep -o 'assets/index-[^"]*\.js' ui/src/main/dist/index.html` + - Both MUST match. +6. `curl -s -o /dev/null -w "%{http_code}" -L http://localhost:8980/opennms/ui/assets/index-*.js` → must be 200 +7. Tell user to hard refresh (`Cmd+Option+R` in Safari, Shift+click reload in Chrome) + +**Never claim something is fixed without completing ALL steps above.** + +### Containers & Podman + +- **Always use `--privileged`** with podman on macOS. JVM gets "Operation not permitted" without it. +- **Container startup takes ~15-20 seconds.** Cap health-check polling at 12 iterations x 5s = 60s. If not ready, check logs and fail. Never wait 5-10 minutes. +- **Use HTTP health checks**, not `opennms status` (unreliable in containers). +- **Verify overlay images before starting containers.** After `podman build`, spot-check critical files. Treat build warnings as errors. +- **NEVER run OpenNMS natively on macOS** — always use container overlay testing. +- **Set test container password to `notdefault` via Jasypt** (SHA-256, iterations=100000, saltSizeBytes=16). Never PUT plaintext passwords to REST API. +- **JSP changes need full container rebuild+restart** — `podman cp` does NOT trigger JSP recompilation. + +### Testing & Verification + +- **E2E tests against live test harness are MANDATORY before any git push.** No exceptions. +- **Never claim something works without proof.** Show actual output — HTTP codes, test results, live verification. +- **Never remove code outside current task scope.** Fix it or leave it alone. + +## Project Overview + +OpenNMS Horizon — open-source enterprise network monitoring. Version 35.x, Java 17, Maven multi-module (~500+ submodules). AGPL v3. + +## Build Commands + +Ships its own Maven in `maven/bin/mvn`. `compile.pl` and `assemble.pl` wrappers handle Java/Maven detection. + +```bash +# Full build (skip tests, ~15-30 min first time) +./compile.pl -DskipTests + +# Assemble into runnable target directory +./assemble.pl -p dir -DskipTests + +# Single module + deps +./compile.pl -DskipTests --projects :opennms-dao -am install + +# Downstream dependents +./compile.pl -t --projects :opennms-dao -amd install + +# Single test class (cd into module first) +cd features/timeseries && ../../maven/bin/mvn test -Dtest=RingBufferTimeseriesWriterTest + +# License validation (CI tarball) +./compile.pl -DskipTests -Denable.license=true -Passemblies -Psmoke install +``` + +## Architecture + +### Module Structure + +- **`core/`** — Shared libraries (API, IPC, SNMP, Spring, DB schema, web assets) +- **`features/`** — Plugins (timeseries, flows, alarms, collection, discovery, events, topology, Kafka/ES) +- **`opennms-dao/`** / **`opennms-dao-api/`** — Data access (Hibernate/PostgreSQL) +- **`opennms-model/`** — Domain model (nodes, events, alarms) +- **`opennms-services/`** — Core daemons (pollerd, collectd, eventd) +- **`opennms-config*/`** — Config model, JAXB, DAO for XML configs +- **`container/`** — Karaf OSGi container (features XML, assembly, Spring↔Karaf bridge) +- **`opennms-full-assembly/`** — Final Horizon assembly +- **`smoke-test/`** — Selenium smoke tests (Testcontainers, `*IT.java`) + +### Karaf/OSGi + +OpenNMS predates Karaf. Web UI + Spring Security are traditional Spring context; Karaf is embedded above. + +**Adding a new Karaf feature requires 3 places:** +1. Feature definition in `container/features/src/main/resources/features*.xml` +2. `featuresBoot` entry in the corresponding `.cfg` file +3. `` section in `container/karaf/pom.xml` + +Key gotchas: +- `` = "skip if packages already exported" (NOT "required dependency") +- `prerequisite="true"` = force parent to fully load before this feature +- OSGi resolution errors in `karaf.log`: read `Unable to resolve root:` chains backwards + +### IPC (3 patterns, each with JMS/Kafka/gRPC impls) + +- **RPC** — Request/response to Minions +- **Sink** — One-way from Minions to Horizon +- **Twin** — Config sync Horizon→Minions + +### Timeseries + +`features/timeseries/` — TSS integration layer, `RingBufferTimeseriesWriter`. Plugins implement `TimeSeriesStorage`. + +## Branch Strategy + +- **`develop`** — Next major release +- **`release-XX.x`** — Stable releases +- CI auto-merges: `foundation-2024` → `release-33.x` → `develop` +- Tags: `opennms-XX.X.X-1` + +## Code Quality + +- **Checkstyle** (`src/main/resources/nms_checks.xml`): `StandardCharsets.UTF_8` not string literals, `MoreObjects.toStringHelper()` not Spring's +- **CI (CircleCI):** Dynamic config auto-detects changes. Force paths via commit keywords: `!build-deploy`, `!smoke`, `!oci`, `!doc`, `!ui` + +## Key Conventions + +- Maven Enforcer blocks banned/duplicate deps — fix with `` +- Custom Spring/Security forks (patched 4.2.x) +- Apache Servicemix wrappers for OSGi +- Artifact IDs without groupId if unique: `--projects :opennms-dao` diff --git a/DEVELOPMENT-SETUP.md b/DEVELOPMENT-SETUP.md new file mode 100644 index 000000000000..8f5dee8f81ef --- /dev/null +++ b/DEVELOPMENT-SETUP.md @@ -0,0 +1,156 @@ +# Local Development Setup + +Complete guide for getting the OpenNMS UI development environment running from scratch. + +--- + +## Prerequisites + +Install all required tooling: + +```bash +# Java 17 (required — enforcer rejects 11 or 23) +brew install openjdk@17 +export JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home + +# Docker runtime (colima) +brew install colima docker docker-buildx docker-compose +mkdir -p ~/.docker/cli-plugins +ln -sf /opt/homebrew/opt/docker-buildx/bin/docker-buildx ~/.docker/cli-plugins/docker-buildx +ln -sf /opt/homebrew/opt/docker-compose/bin/docker-compose ~/.docker/cli-plugins/docker-compose + +# Podman (for test container lab) +brew install podman +podman machine init +podman machine set --rootful --memory 8192 +podman machine start + +# pnpm (for web-assets / Vue menu builds) +npm install -g pnpm +``` + +--- + +## Quick Start: UI Development (Test Container) + +This is the primary workflow for UI work. Uses the released `35.0.4` base image with an overlay of local UI changes — much faster than a full Maven build. + +### 1. Start Postgres + +```bash +cd tools/local_development/postgres +docker compose up -d +``` + +### 2. Start colima with enough memory + +OpenNMS needs at least 6–8 GB. The default 2 GB colima allocation causes an OOM kill. + +```bash +colima start --memory 8 --cpu 4 +# If already running with less memory: +colima stop && colima start --memory 8 --cpu 4 +``` + +### 3. Build the test container image + +```bash +./build-dark-mode-overlay.sh +``` + +This builds webpack assets, the Vue menu (vite), `opennms-webapp-rest`, then assembles an overlay on top of `opennms/horizon:35.0.4`. Result: `localhost/opennms/horizon:35.0.5-dark-mode`. + +### 4. Start the container + +```bash +podman rm -f test-opennms 2>/dev/null +podman run -d --name test-opennms --privileged \ + -p 8980:8980 -p 8101:8101 \ + -e POSTGRES_HOST=host.containers.internal \ + -e POSTGRES_PORT=5432 \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=postgres \ + -e OPENNMS_DBNAME=opennms \ + -e OPENNMS_DBUSER=opennms \ + -e OPENNMS_DBPASS=opennms \ + localhost/opennms/horizon:35.0.5-dark-mode -s +``` + +### 5. Wait for startup, then set password + +```bash +# Wait for ready +until curl -s -o /dev/null -w '%{http_code}' -u admin:admin \ + http://localhost:8980/opennms/rest/info | grep -q 200; do sleep 5; done +echo "ready" + +# Set password to notdefault +podman exec test-opennms /opt/opennms/bin/password admin notdefault +``` + +### 6. Open + +**http://localhost:8980/opennms** — login: `admin` / `notdefault` + +--- + +## Topology Lab + +Spins up a 5-node spine/leaf network (FRR OSPF + ISIS + LLDP + SNMP) for EnLinkd testing. Requires `test-opennms` to be running first. + +```bash +./start-topology-lab.sh # start (tears down + rebuilds if already running) +./start-topology-lab.sh --teardown # teardown only +./start-topology-lab.sh --rebuild # force topology-node image rebuild +``` + +After ~30–60s for EnLinkd collection: **http://localhost:8980/opennms/#/topology** + +Verify layers: +```bash +podman exec topo-spine-01 vtysh -c "show ip ospf neighbor" # OSPF +podman exec topo-spine-01 vtysh -c "show isis neighbor" # ISIS +podman exec topo-spine-01 lldpcli show neighbors # LLDP +``` + +--- + +## Full Source Build (rarely needed) + +Only required when changing Java backend code. UI-only changes use the overlay workflow above. + +```bash +export JAVA_HOME=/opt/homebrew/opt/openjdk@17/libexec/openjdk.jdk/Contents/Home + +# 1. Build all Maven modules +./compile.pl -DskipTests install + +# 2. Package assembly tarball +./assemble.pl -Dopennms.home=/opt/opennms -DskipTests + +# 3. Build and load Docker image (use oci+install, not image — colima uses a non-default buildx builder) +cd opennms-container/core && make oci && make install +``` + +--- + +## Branches + +| Branch | Purpose | +|--------|---------| +| `ui-refactor` | Full UI overhaul (topology panels, Perses dashboards, etc.) — was `feature/jmx-config-vue` | +| `develop` | Main branch | + +--- + +## Troubleshooting + +**OOM kill (exit 137 or exit 1 with `OutOfMemoryError: Java heap space`):** The podman machine needs at least 8 GB. Check with `podman machine inspect` — if Memory is 2048, run: `podman machine stop && podman machine set --memory 8192 && podman machine start`. Also ensure colima (used for Docker builds) has enough memory: `colima stop && colima start --memory 8 --cpu 4`. + +**`make image` fails with buildx error:** Use `make oci && make install` instead. The `image` target requires a buildx builder named exactly `default`, which colima doesn't provide. + +**npm `ETARGET` errors during Maven build:** The `package-lock.json` has stale pinned versions. Delete it and regenerate: `cd core/web-assets && rm package-lock.json && npm install` + +**`podman: Cannot connect`:** Run `podman machine start` + +**`docker buildx` not found:** `brew install docker-buildx && ln -sf /opt/homebrew/opt/docker-buildx/bin/docker-buildx ~/.docker/cli-plugins/docker-buildx` diff --git a/build-dark-mode-overlay.sh b/build-dark-mode-overlay.sh new file mode 100755 index 000000000000..4fb691fe7165 --- /dev/null +++ b/build-dark-mode-overlay.sh @@ -0,0 +1,397 @@ +#!/usr/bin/env bash +# build-dark-mode-overlay.sh +# Builds the dark-mode/modern-ui overlay image for container testing. +# Compiles web-assets (webpack) and the Vue menu (vite), then assembles +# a minimal Dockerfile overlay on top of the released 35.0.4 image. +# +# Using 35.0.4 as base (not 35.0.5-api-tokens) avoids a Karaf feature +# resolution crash in the api-tokens overlay. Our vite build already +# includes the LightDarkMode toggle, so we don't need the api-tokens +# base for dark mode functionality. +# +# Usage: ./build-dark-mode-overlay.sh +# Result: podman image tagged localhost/opennms/horizon:35.0.5-dark-mode +# Run + password setup instructions printed at the end. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +OVERLAY_DIR="$(mktemp -d)" +IMAGE_TAG="localhost/opennms/horizon:35.0.5-dark-mode" +ONMS_BASE_IMAGE="docker.io/opennms/horizon:35.0.4" + +echo "==> overlay staging dir: ${OVERLAY_DIR}" + +# --------------------------------------------------------------------------- +# 1. Build web-assets (webpack) — produces dark-mode.css + modern-ui.css +# --------------------------------------------------------------------------- +echo "" +echo "==> [1/4] Building core/web-assets (webpack)..." +cd "${SCRIPT_DIR}/core/web-assets" +# pnpm is used by this module. +# Exit code 2 from webpack means there were errors in OTHER scss files (pre-existing +# vaadin-theme import issues in the codebase). Our new bundles are still emitted. +# We verify success by checking for the output files explicitly. +pnpm run webpack || true +# Output lands in target/dist/assets/ +WEBASSETS_DIST="${SCRIPT_DIR}/core/web-assets/target/dist/assets" + +if [[ ! -f "${WEBASSETS_DIST}/dark-mode.css" ]]; then + echo "ERROR: dark-mode.css not found in ${WEBASSETS_DIST} — webpack build failed" >&2 + exit 1 +fi +if [[ ! -f "${WEBASSETS_DIST}/modern-ui.css" ]]; then + echo "ERROR: modern-ui.css not found in ${WEBASSETS_DIST} — webpack build failed" >&2 + exit 1 +fi +if [[ ! -f "${WEBASSETS_DIST}/assets.json" ]]; then + echo "ERROR: assets.json not found in ${WEBASSETS_DIST} — webpack build failed" >&2 + exit 1 +fi +echo " dark-mode.css: OK" +echo " modern-ui.css: OK" + +# --------------------------------------------------------------------------- +# 2. Build Vue menu (vite) — produces updated index.js with dark mode toggle +# --------------------------------------------------------------------------- +echo "" +echo "==> [2/4] Building ui/ menu (vite build:menu)..." +cd "${SCRIPT_DIR}/ui" +pnpm run build:menu +# vite.config.menu.ts sets root='./src/menu' and outDir='./dist-menu' +# so output lands in ui/src/menu/dist-menu/ +UI_DIST="${SCRIPT_DIR}/ui/src/menu/dist-menu" + +if [[ ! -f "${UI_DIST}/assets/index.js" ]]; then + echo "ERROR: ui dist-menu/assets/index.js not found" >&2 + exit 1 +fi +echo " index.js: OK" + +# --------------------------------------------------------------------------- +# 3. Build opennms-webapp-rest (needed for JMX Config Generator backend) +# --------------------------------------------------------------------------- +echo "" +echo "==> [3/4] Building opennms-webapp-rest..." +cd "${SCRIPT_DIR}" +./compile.pl -DskipTests -Ddisable.checkstyle --projects :opennms-webapp-rest install 2>&1 | tail -5 +# Find the built jar dynamically — version may differ from base image +WEBAPP_REST_JAR=$(find "${SCRIPT_DIR}/opennms-webapp-rest/target" -path '*/WEB-INF/lib/opennms-webapp-rest-*.jar' -newer "${SCRIPT_DIR}/opennms-webapp-rest/pom.xml" | sort | tail -1) +if [[ -z "${WEBAPP_REST_JAR}" || ! -f "${WEBAPP_REST_JAR}" ]]; then + echo "ERROR: opennms-webapp-rest jar not found in target/" >&2 + exit 1 +fi +echo " opennms-webapp-rest.jar: OK ($(basename "${WEBAPP_REST_JAR}"))" + +# --------------------------------------------------------------------------- +# 4. Stage overlay directory +# --------------------------------------------------------------------------- +echo "" +echo "==> [4/4] Staging overlay..." + +# web-assets CSS: copy the two new bundles + their sourcemaps if present. +mkdir -p "${OVERLAY_DIR}/assets" +cp "${WEBASSETS_DIST}/dark-mode.css" "${OVERLAY_DIR}/assets/" +cp "${WEBASSETS_DIST}/modern-ui.css" "${OVERLAY_DIR}/assets/" +[[ -f "${WEBASSETS_DIST}/dark-mode.css.map" ]] && cp "${WEBASSETS_DIST}/dark-mode.css.map" "${OVERLAY_DIR}/assets/" +[[ -f "${WEBASSETS_DIST}/modern-ui.css.map" ]] && cp "${WEBASSETS_DIST}/modern-ui.css.map" "${OVERLAY_DIR}/assets/" + +# Patch the base image's assets.json to add only the two new bundle entries. +# We CANNOT replace assets.json wholesale — the manifest entry contains inline +# webpack JS that must match the bundle files already in the base image. +# Extract the new entries from our build and inject them into the base image's copy. +podman run --rm --entrypoint cat "${ONMS_BASE_IMAGE}" \ + /opt/opennms/jetty-webapps/opennms/assets/assets.json \ + > "${OVERLAY_DIR}/assets/assets-base.json" + +python3 - "${OVERLAY_DIR}/assets/assets-base.json" \ + "${WEBASSETS_DIST}/assets.json" \ + "${OVERLAY_DIR}/assets/assets.json" \ + "${OVERLAY_DIR}/assets/assets.min.json" <<'PYEOF' +import json, sys + +base_path, new_path, out_path, out_min_path = sys.argv[1:] + +with open(base_path) as f: + base = json.load(f) +with open(new_path) as f: + new_build = json.load(f) + +base['dark-mode'] = new_build['dark-mode'] +base['modern-ui'] = new_build['modern-ui'] + +for p in (out_path, out_min_path): + with open(p, 'w') as f: + json.dump(base, f, indent=2) +PYEOF +echo " assets.json: patched (added dark-mode + modern-ui to base image entries)" + +# Vue menu assets (full replacement — we changed index.js) +mkdir -p "${OVERLAY_DIR}/ui-components/assets" +cp -r "${UI_DIST}/assets/." "${OVERLAY_DIR}/ui-components/assets/" + +# JSPs (need full container rebuild — Jetty caches compiled JSPs) +mkdir -p "${OVERLAY_DIR}/includes" +cp "${SCRIPT_DIR}/opennms-webapp/src/main/webapp/includes/bootstrap.jsp" \ + "${OVERLAY_DIR}/includes/bootstrap.jsp" +cp "${SCRIPT_DIR}/opennms-webapp/src/main/webapp/index.jsp" \ + "${OVERLAY_DIR}/index.jsp" +# mibCompiler.jsp — redirect to Vue SPA +mkdir -p "${OVERLAY_DIR}/admin" +cp "${SCRIPT_DIR}/opennms-webapp/src/main/webapp/admin/mibCompiler.jsp" \ + "${OVERLAY_DIR}/admin/mibCompiler.jsp" +# node.jsp — redirect to Vue SPA at /#/node/:id +mkdir -p "${OVERLAY_DIR}/element" +cp "${SCRIPT_DIR}/opennms-webapp/src/main/webapp/element/node.jsp" \ + "${OVERLAY_DIR}/element/node.jsp" + +# event/detail.jsp — redirect to Vue SPA at /#/event/:id +mkdir -p "${OVERLAY_DIR}/event" +cp "${SCRIPT_DIR}/opennms-webapp/src/main/webapp/WEB-INF/jsp/event/detail.jsp" \ + "${OVERLAY_DIR}/event/detail.jsp" + +# manageSnmpCollections.jsp — redirect to Vue SPA at /#/snmp-collections-config +cp "${SCRIPT_DIR}/opennms-webapp/src/main/webapp/admin/manageSnmpCollections.jsp" \ + "${OVERLAY_DIR}/admin/manageSnmpCollections.jsp" + + +# opennms-webapp-rest jar — rename to match base image version so COPY replaces it +# DashboardRestService references OnmsDashboard (not in 35.0.4 base model jar), and the +# 35.0.5-SNAPSHOT model jar requires core packages at version 36.x (not available in the +# 35.0.4 Karaf feature repo). Strip DashboardRestService from the jar to avoid the entire +# cascade — we don't need it for JMX Config Generator or MIB Compiler testing. +WEBAPP_REST_BASENAME="opennms-webapp-rest-35.0.4.jar" +mkdir -p "${OVERLAY_DIR}/webapp-rest-lib" +cp "${WEBAPP_REST_JAR}" "${OVERLAY_DIR}/webapp-rest-lib/${WEBAPP_REST_BASENAME}" +zip -d "${OVERLAY_DIR}/webapp-rest-lib/${WEBAPP_REST_BASENAME}" \ + 'org/opennms/web/rest/v2/DashboardRestService.class' \ + 'org/opennms/web/rest/v2/DashboardRestService$*.class' 2>/dev/null || true +echo " opennms-webapp-rest.jar: OK (DashboardRestService stripped)" + +# Spring context XML — base image doesn't have jmxconfig in component-scan +mkdir -p "${OVERLAY_DIR}/spring-context" +cp "${SCRIPT_DIR}/opennms-webapp-rest/src/main/webapp/WEB-INF/applicationContext-cxf-rest-v2.xml" \ + "${OVERLAY_DIR}/spring-context/" + +# Also copy .js stubs so the entry is resolvable if anything tries to load them +cp "${WEBASSETS_DIST}/dark-mode.js" "${OVERLAY_DIR}/assets/" +cp "${WEBASSETS_DIST}/modern-ui.js" "${OVERLAY_DIR}/assets/" +[[ -f "${WEBASSETS_DIST}/dark-mode.js.map" ]] && cp "${WEBASSETS_DIST}/dark-mode.js.map" "${OVERLAY_DIR}/assets/" +[[ -f "${WEBASSETS_DIST}/modern-ui.js.map" ]] && cp "${WEBASSETS_DIST}/modern-ui.js.map" "${OVERLAY_DIR}/assets/" + +# --------------------------------------------------------------------------- +# SNMP: snmpd daemon config + entrypoint wrapper + OpenNMS client config +# --------------------------------------------------------------------------- +cat > "${OVERLAY_DIR}/snmpd.conf" <<'SNMPD' +rocommunity public 127.0.0.1 +syslocation "OpenNMS Test Container" +syscontact "admin@localhost" +SNMPD + +cat > "${OVERLAY_DIR}/entrypoint-wrapper.sh" <<'WRAPPER' +#!/bin/bash +# Start snmpd on port 1161 (no root required) in background, log to stdout. +# Don't use set -e here — snmpd failure is non-fatal; OpenNMS should still start. +snmpd -Lo -p /tmp/snmpd.pid -c /etc/snmp/snmpd.conf udp:1161 & +exec /entrypoint.sh "$@" +WRAPPER +chmod +x "${OVERLAY_DIR}/entrypoint-wrapper.sh" + +mkdir -p "${OVERLAY_DIR}/etc/imports" + +cat > "${OVERLAY_DIR}/etc/snmp-config.xml" <<'SNMPCFG' + + + 127.0.0.1 + + + + + +SNMPCFG + +cat > "${OVERLAY_DIR}/etc/enlinkd-configuration.xml" <<'ENLINKD' + + +ENLINKD + +cat > "${OVERLAY_DIR}/etc/imports/Self.xml" <<'REQUISITION' + + + + + + + + + + +REQUISITION + +echo " snmpd.conf + entrypoint-wrapper.sh + snmp-config.xml + enlinkd-configuration.xml + imports/Self.xml: staged" + +# --------------------------------------------------------------------------- +# 4. Write Dockerfile +# --------------------------------------------------------------------------- +# NOTE: The container's entrypoint (/entrypoint.sh) builds the JVM command +# directly and does NOT source opennms.conf. The only way to pass extra +# JVM system properties is through the JAVA_OPTS environment variable, +# which is appended verbatim to the exec call: +# exec java ${OPENNMS_JAVA_OPTS} ${JAVA_OPTS} -jar opennms_bootstrap.jar start +# +# AssetLocatorImpl reads assets.json from CLASSPATH by default (from the +# web-assets-*.jar). Setting org.opennms.web.assets.path overrides that +# to the filesystem path, which is where our patched assets.json lives. +cat > "${OVERLAY_DIR}/Dockerfile" <frontPage.jsp|index.jsp|' /opt/opennms/jetty-webapps/opennms/WEB-INF/web.xml + +# jmxconfiggenerator + its transitive dep namecutter must be in the Bootstrap server +# classpath (not WEB-INF/lib) so Jetty's WebAppClassLoader can resolve them. +RUN cp /opt/opennms/system/org/opennms/features/jmxconfiggenerator/35.0.4/jmxconfiggenerator-35.0.4.jar /opt/opennms/lib/jmxconfiggenerator-35.0.4.jar && cp /opt/opennms/system/org/opennms/features/org.opennms.features.name-cutter/35.0.4/org.opennms.features.name-cutter-35.0.4.jar /opt/opennms/lib/org.opennms.features.name-cutter-35.0.4.jar + +# mib-compiler + jsmiparser for SNMP MIB Compiler REST endpoints (Bootstrap classpath) +# jsmiparser jars are embedded inside the mib-compiler OSGi bundle via Embed-Dependency. +# The JVM can't see embedded JARs on the classpath, so we extract them separately. +RUN cp /opt/opennms/system/org/opennms/features/org.opennms.features.mib-compiler/35.0.4/org.opennms.features.mib-compiler-35.0.4.jar /opt/opennms/lib/org.opennms.features.mib-compiler-35.0.4.jar && \ + cd /tmp && unzip -o /opt/opennms/system/org/opennms/features/org.opennms.features.mib-compiler/35.0.4/org.opennms.features.mib-compiler-35.0.4.jar jsmiparser-api-0.14.jar jsmiparser-util-0.14.jar && \ + mv /tmp/jsmiparser-api-0.14.jar /opt/opennms/lib/ && \ + mv /tmp/jsmiparser-util-0.14.jar /opt/opennms/lib/ + +# Updated opennms-webapp-rest with JMX Config + MIB Compiler REST endpoints +COPY --chown=10001:10001 webapp-rest-lib/${WEBAPP_REST_BASENAME} /opt/opennms/jetty-webapps/opennms/WEB-INF/lib/opennms-webapp-rest-35.0.4.jar + +# Spring context with jmxconfig + mibcompiler packages in component-scan (base image lacks them) +COPY --chown=10001:10001 spring-context/applicationContext-cxf-rest-v2.xml /opt/opennms/jetty-webapps/opennms/WEB-INF/applicationContext-cxf-rest-v2.xml + +# Tell AssetLocatorImpl to load assets.json from the filesystem (not the +# classpath JAR that lacks dark-mode/modern-ui entries). +# The entrypoint appends \${JAVA_OPTS} to the JVM exec line, so ENV is the +# correct mechanism — opennms.conf is NOT sourced by the container entrypoint. +ENV JAVA_OPTS="-Dorg.opennms.web.assets.path=/opt/opennms/jetty-webapps/opennms/assets/" + +# Install net-snmp (daemon) + net-snmp-utils (snmpwalk for verification) +RUN microdnf install -y net-snmp net-snmp-utils && microdnf clean all +COPY snmpd.conf /etc/snmp/snmpd.conf +COPY entrypoint-wrapper.sh /entrypoint-wrapper.sh +RUN chmod +x /entrypoint-wrapper.sh + +# OpenNMS SNMP client config + self-provisioning requisition +COPY --chown=10001:10001 etc/snmp-config.xml /opt/opennms/etc/snmp-config.xml +COPY --chown=10001:10001 etc/enlinkd-configuration.xml /opt/opennms/etc/enlinkd-configuration.xml +COPY --chown=10001:10001 etc/imports/Self.xml /opt/opennms/etc/imports/Self.xml + +ENTRYPOINT ["/entrypoint-wrapper.sh"] + +USER 10001 +DOCKERFILE +echo " Dockerfile: written (ENV JAVA_OPTS sets web assets path)" + +# --------------------------------------------------------------------------- +# 5. Build image +# --------------------------------------------------------------------------- +echo "" +echo "==> Building container image: ${IMAGE_TAG}" +podman build --no-cache -t "${IMAGE_TAG}" "${OVERLAY_DIR}" + +echo "" +echo "==> Build complete: ${IMAGE_TAG}" +echo "" +echo "==> Overlay dir (kept for inspection): ${OVERLAY_DIR}" +echo "" +echo "============================================================" +echo " Run + setup (needs postgres on host):" +echo "============================================================" +echo "" +echo "# 1. Start" +echo "podman rm -f test-opennms 2>/dev/null; podman run -d --name test-opennms --privileged \\" +echo " -p 8980:8980 -p 8101:8101 \\" +echo " -e POSTGRES_HOST=host.containers.internal \\" +echo " -e POSTGRES_PORT=5432 \\" +echo " -e POSTGRES_USER=postgres \\" +echo " -e POSTGRES_PASSWORD=postgres \\" +echo " -e OPENNMS_DBNAME=opennms \\" +echo " -e OPENNMS_DBUSER=opennms \\" +echo " -e OPENNMS_DBPASS=opennms \\" +echo " ${IMAGE_TAG} -s" +echo "" +echo "# 2. Wait for startup" +echo "until curl -s -o /dev/null -w '%{http_code}' -u admin:admin http://localhost:8980/opennms/rest/info | grep -q 200; do sleep 5; done && echo ready" +echo "" +echo "# 3. Set password to notdefault via Jasypt (REST API stores plaintext, breaking Jasypt verification)" +echo "HASH=\$(podman exec test-opennms java -cp /opt/opennms/lib/jasypt-1.9.3.jar \\" +echo " org.jasypt.intf.cli.JasyptStringDigestCLI \\" +echo " input='notdefault' algorithm=SHA-256 saltSizeBytes=16 iterations=100000 2>/dev/null \\" +echo " | tail -3 | head -1)" +echo "podman exec test-opennms sed -i \\" +echo " \"s|.*|\${HASH}|\" \\" +echo " /opt/opennms/etc/users.xml" +echo "# users.xml auto-reloads in ~5s" +echo "" +echo "# 4. Open: http://localhost:8980/opennms/ (login: admin / notdefault)" +echo "" +echo " What to verify:" +echo " 1. Sun/moon icon visible in the Feather app bar (top right)" +echo " 2. Clicking it toggles dark mode on the JSP content area" +echo " 3. Reload preserves the theme (no flash of light mode)" +echo " 4. Topology / dashboard Vaadin iframes also go dark" +echo "" +echo "# Cleanup:" +echo " podman rm -f test-opennms" diff --git a/core/schema/src/main/liquibase/35.0.5/changelog.xml b/core/schema/src/main/liquibase/35.0.5/changelog.xml new file mode 100644 index 000000000000..dcd7958e0430 --- /dev/null +++ b/core/schema/src/main/liquibase/35.0.5/changelog.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/schema/src/main/liquibase/36.0.0/changelog.xml b/core/schema/src/main/liquibase/36.0.0/changelog.xml new file mode 100644 index 000000000000..15b41d6ffd7d --- /dev/null +++ b/core/schema/src/main/liquibase/36.0.0/changelog.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/core/schema/src/main/liquibase/changelog.xml b/core/schema/src/main/liquibase/changelog.xml index dc5faa409220..016242714792 100644 --- a/core/schema/src/main/liquibase/changelog.xml +++ b/core/schema/src/main/liquibase/changelog.xml @@ -103,6 +103,8 @@ + + diff --git a/core/web-assets/package.json b/core/web-assets/package.json index 08a851314b05..baab91699968 100644 --- a/core/web-assets/package.json +++ b/core/web-assets/package.json @@ -211,6 +211,7 @@ }, "scripts": { "preinstall": "npx only-allow pnpm", + "postinstall": "node scripts/patch-flot-legend.js", "webpack": "cross-env NODE_OPTIONS=\"--max-old-space-size=4096 --openssl-legacy-provider\" webpack --colors --display-error-details --env=development", "webpack-release": "cross-env NODE_OPTIONS=\"--max-old-space-size=4096 --openssl-legacy-provider\" webpack --colors --display-error-details --env=production", "check-git": "node check-git.js", diff --git a/core/web-assets/patches/flot-legend@2.0.1-SNAPSHOT.patch b/core/web-assets/patches/flot-legend@2.0.1-SNAPSHOT.patch new file mode 100644 index 000000000000..1db5cfbe5c22 --- /dev/null +++ b/core/web-assets/patches/flot-legend@2.0.1-SNAPSHOT.patch @@ -0,0 +1,12 @@ +--- a/jquery.flot.legend.js 2026-03-15 01:18:45 ++++ b/jquery.flot.legend.js 2026-03-29 23:13:34 +@@ -119,7 +119,8 @@ + + CanvasLegend.prototype.drawText = function(text) { + +- this.ctx.fillStyle = "black"; ++ this.ctx.fillStyle = (this.opts.legend.style && this.opts.legend.style.color) || ++ (document.body.classList.contains('open-dark') ? '#c8d4e8' : 'black'); + this.ctx.font = this.fontSize + "px Monospace"; + this.ctx.textAlign = "left"; + diff --git a/core/web-assets/scripts/deploy-to-container.sh b/core/web-assets/scripts/deploy-to-container.sh new file mode 100755 index 000000000000..942a107587be --- /dev/null +++ b/core/web-assets/scripts/deploy-to-container.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Deploy ALL compiled webpack assets to a running OpenNMS container. +# IMPORTANT: Always deploy all assets together — partial deploys cause webpack +# module ID mismatches that silently break JS across chunks (e.g., $ undefined). +# +# Usage: ./scripts/deploy-to-container.sh [container-name] +# Default container name: test-opennms + +set -euo pipefail + +CONTAINER=${1:-test-opennms} +ASSETS_SRC="$(cd "$(dirname "$0")/.." && pwd)/target/dist/assets" +ASSETS_DEST="/opt/opennms/jetty-webapps/opennms/assets" + +if ! podman ps --format '{{.Names}}' | grep -q "^${CONTAINER}$"; then + echo "ERROR: container '${CONTAINER}' is not running" >&2 + exit 1 +fi + +if [ ! -f "${ASSETS_SRC}/assets.json" ]; then + echo "ERROR: ${ASSETS_SRC}/assets.json not found — run 'npm run webpack' first" >&2 + exit 1 +fi + +echo "Deploying all assets to ${CONTAINER}:${ASSETS_DEST} ..." +podman cp "${ASSETS_SRC}/." "${CONTAINER}:${ASSETS_DEST}/" + +# A dev build (npm run webpack) only writes assets.json, not assets.min.json. +# The server uses assets.min.json (minified=true by default), so we need to +# merge any entries that exist in assets.json but are missing from assets.min.json. +echo "Merging assets.json entries into assets.min.json ..." +MERGE_PY=' +import json, os, sys +d = "/opt/opennms/jetty-webapps/opennms/assets" +full = json.load(open(os.path.join(d, "assets.json"))) +mini = json.load(open(os.path.join(d, "assets.min.json"))) +added = [k for k in full if k not in mini] +for k in added: mini[k] = full[k] +if added: + json.dump(mini, open(os.path.join(d, "assets.min.json"), "w"), indent=4, sort_keys=True) + print(" Added to assets.min.json:", ", ".join(sorted(added))) +else: + print(" assets.min.json already up to date.") +' +podman exec "${CONTAINER}" python3 -c "${MERGE_PY}" + +echo "Done. Hard-reload the browser to pick up new file versions." diff --git a/core/web-assets/scripts/patch-flot-legend.js b/core/web-assets/scripts/patch-flot-legend.js new file mode 100644 index 000000000000..bf4628494844 --- /dev/null +++ b/core/web-assets/scripts/patch-flot-legend.js @@ -0,0 +1,35 @@ +/** + * Patches flot-legend's CanvasLegend.drawText to use a theme-aware text color + * instead of hardcoded black, enabling dark mode support. + * + * Run automatically via the postinstall npm/pnpm script. + */ +const fs = require('fs'); +const path = require('path'); + +const target = path.join(__dirname, '..', 'node_modules', 'flot-legend', 'jquery.flot.legend.js'); + +if (!fs.existsSync(target)) { + console.log('patch-flot-legend: file not found, skipping'); + process.exit(0); +} + +const original = ' this.ctx.fillStyle = "black";'; +const patched = + ' this.ctx.fillStyle = (this.opts.legend.style && this.opts.legend.style.color) ||\n' + + " (document.body.classList.contains('open-dark') ? '#c8d4e8' : 'black');"; + +const src = fs.readFileSync(target, 'utf8'); + +if (src.includes(patched)) { + console.log('patch-flot-legend: already applied, skipping'); + process.exit(0); +} + +if (!src.includes(original)) { + console.warn('patch-flot-legend: expected string not found — flot-legend may have been updated. Patch skipped.'); + process.exit(0); +} + +fs.writeFileSync(target, src.replace(original, patched), 'utf8'); +console.log('patch-flot-legend: applied successfully'); diff --git a/core/web-assets/src/main/assets/js/apps/onms-graph/index.js b/core/web-assets/src/main/assets/js/apps/onms-graph/index.js index 6acdd2f843a3..f6134de60e9a 100644 --- a/core/web-assets/src/main/assets/js/apps/onms-graph/index.js +++ b/core/web-assets/src/main/assets/js/apps/onms-graph/index.js @@ -36,6 +36,17 @@ const holder = require('vendor/holder-js'); let cssLoaded = false; +const isDarkMode = () => document.body.classList.contains('open-dark'); +const DARK_TEXT = '#c8d4e8'; +const LIGHT_TEXT = '#333333'; + +// Patch drawHook so the graph title uses theme-aware color instead of default black +const origDrawHook = Backshift.Graph.Flot.prototype.drawHook; +Backshift.Graph.Flot.prototype.drawHook = function(plot, canvascontext) { + canvascontext.fillStyle = isDarkMode() ? DARK_TEXT : LIGHT_TEXT; + origDrawHook.call(this, plot, canvascontext); +}; + const getGraphingEngine = () => { let graphingEngine = 'png'; if (window.onmsGraphContainers !== undefined diff --git a/core/web-assets/src/main/assets/js/apps/timeline-resize/index.js b/core/web-assets/src/main/assets/js/apps/timeline-resize/index.js index f92ad0331e2d..3ab5be403744 100644 --- a/core/web-assets/src/main/assets/js/apps/timeline-resize/index.js +++ b/core/web-assets/src/main/assets/js/apps/timeline-resize/index.js @@ -29,18 +29,23 @@ const getSize = function(element) { const td = element.closest('td')[0]; if (td !== undefined) { - // get the td's padding - const s = getComputedStyle(td).padding; - // remove 'px' from string like '4.8px' and convert to float - const p = parseFloat(s.substr(0, s.length - 2)); - // subtract the padding twice - return Math.round(td.offsetWidth - 2 * p); + // Use individual longhand properties — getComputedStyle().padding (shorthand) + // returns '' in modern Chrome/Safari regardless of the CSS value, while + // paddingLeft/paddingRight always return a computed pixel value. + const style = getComputedStyle(td); + const pl = parseFloat(style.paddingLeft) || 0; + const pr = parseFloat(style.paddingRight) || 0; + const w = Math.round(td.offsetWidth - pl - pr); + if (w > 0) { return w; } + // offsetWidth was 0 — layout not yet complete; fall through to container fallback } - // otherwise, fall back to the old way of calculating - const container = element.closest('div'); // This is the panel, not the cell that contains the IMG - if (container !== undefined) { - return Math.round(container.width() * RELATIVE_SIZE); + // Fall back to a portion of the nearest containing div's width. + // This handles the case where the td hasn't been laid out yet. + const container = element.closest('div'); + if (container && container.length > 0) { + const cw = Math.round(container.innerWidth() * RELATIVE_SIZE); + if (cw > 0) { return cw; } } return NaN; @@ -53,7 +58,7 @@ const recalculateBox = debounce(() => { for (let i=0; i < imgs.length; i++) { const img = $(imgs[i]); const w = getSize(img); - if (w) { + if (w > 0) { const imgsrc = img.data('imgsrc') + w; img.attr('src', imgsrc); } @@ -63,12 +68,15 @@ const recalculateBox = debounce(() => { for (let i=0; i < spans.length; i++) { const span = $(spans[i]); const w = getSize(span); - if (w && span.data('src')) { + if (w > 0 && span.data('src')) { const htmlsrc = span.data('src') + w; span.load(String(htmlsrc)); } } }, DEBOUNCE_RATE); +// Fire on document ready (layout may still be pending for complex pages) $(document).ready(recalculateBox); +// Fire again on window load (after all resources and layout are fully complete) +$(window).on('load', recalculateBox); window.addEventListener('resize', recalculateBox); diff --git a/core/web-assets/src/main/assets/style/dark-mode.scss b/core/web-assets/src/main/assets/style/dark-mode.scss new file mode 100644 index 000000000000..4821bcc86a09 --- /dev/null +++ b/core/web-assets/src/main/assets/style/dark-mode.scss @@ -0,0 +1,2338 @@ +/** + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * 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. + */ + +// Dark mode overrides for the legacy Bootstrap 4 content area. +// Activated when the Feather design system applies the "open-dark" class to . +// Uses the same color palette as Feather's open-dark theme for consistency. + +// Blue-tinted dark palette — subtle warmth, not pure black +$dark-bg: #131620; // deep blue-black +$dark-surface: #1a1e2e; // cards, inputs +$dark-surface-raised: #222639; // elevated cards, headers +$dark-surface-overlay: #2a3045; // dropdowns, modals, popovers +$dark-border: rgba(140, 160, 200, 0.18); +$dark-border-light: rgba(140, 160, 200, 0.10); +$dark-text: #e8e8e8; // high contrast primary text +$dark-text-secondary: #b0b0b0; // clear secondary text +$dark-text-muted: #777777; // muted but still readable +$dark-link: #6ea8fe; // bright blue accent link +$dark-link-hover: #9ec5fe; + +// Severity accent colors — stronger contrast on dark backgrounds +$dark-severity-critical-bg: rgba(239, 68, 68, 0.30); +$dark-severity-critical-border: #ef4444; +$dark-severity-major-bg: rgba(249, 115, 22, 0.28); +$dark-severity-major-border: #f97316; +$dark-severity-minor-bg: rgba(245, 158, 11, 0.26); +$dark-severity-minor-border: #f59e0b; +$dark-severity-warning-bg: rgba(234, 179, 8, 0.26); +$dark-severity-warning-border: #eab308; +$dark-severity-normal-bg: rgba(34, 197, 94, 0.26); +$dark-severity-normal-border: #22c55e; +$dark-severity-indeterminate-bg: rgba(59, 130, 246, 0.26); +$dark-severity-indeterminate-border: #3b82f6; +$dark-severity-cleared-bg: rgba(156, 163, 175, 0.22); +$dark-severity-cleared-border: #6b7280; + +// Early paint: html gets the theme class before body exists +html.open-dark { + background-color: $dark-bg; + color-scheme: dark; +} + +// Use both body.open-dark and html.open-dark so that Vaadin iframe pages +// (where the GWT engine may overwrite body classes) still get dark mode +// via the class which Vaadin never touches. +body.open-dark, html.open-dark { + background-color: $dark-bg; + color: $dark-text; + + // ===== Core Bootstrap overrides ===== + + // Links + a { + color: $dark-link; + &:hover, &:focus { + color: $dark-link-hover; + } + } + + // Headings + h1, h2, h3, h4, h5, h6, + .h1, .h2, .h3, .h4, .h5, .h6 { + color: $dark-text; + } + + // Text utilities + .text-dark { color: $dark-text !important; } + .text-muted { color: $dark-text-muted !important; } + .text-body { color: $dark-text !important; } + + // Background utilities + .bg-white { background-color: $dark-surface !important; } + .bg-light { background-color: $dark-surface-raised !important; } + + // ===== Cards ===== + // !important needed to beat Bootstrap's .card { background-color: #fff } and + // modern-ui's .card { background: #ffffff } which may otherwise win in some + // cascade orderings when vendor CSS reloads after dark-mode.css. + .card { + background-color: $dark-surface-raised !important; + border-color: $dark-border; + color: $dark-text; + } + + .card-header { + background-color: $dark-surface-overlay !important; + border-bottom-color: $dark-border; + color: $dark-text; + } + + .card-footer { + background-color: $dark-surface-overlay !important; + border-top-color: $dark-border; + color: $dark-text-secondary; + } + + // ===== Tables ===== + .table { + color: $dark-text; + + th, td { + border-top-color: $dark-border; + } + + thead th { + background-color: $dark-surface-raised; + border-bottom-color: $dark-border; + color: $dark-text-secondary; + } + } + + .table-bordered { + border-color: $dark-border; + + th, td { + border-color: $dark-border; + } + } + + .table-striped tbody tr:nth-of-type(odd) { + background-color: rgba(255, 255, 255, 0.03); + } + + .table-hover tbody tr:hover { + background-color: rgba(255, 255, 255, 0.06); + color: $dark-text; + } + + // ===== Forms ===== + .form-control { + background-color: $dark-surface; + border-color: $dark-border; + color: $dark-text; + + &:focus { + background-color: $dark-surface; + border-color: $dark-link; + color: $dark-text; + box-shadow: 0 0 0 0.2rem rgba(125, 146, 255, 0.25); + } + + &::placeholder { + color: $dark-text-muted; + } + + &:disabled, &[readonly] { + background-color: $dark-surface-raised; + color: $dark-text-muted; + } + } + + .form-control-plaintext { + color: $dark-text; + } + + select.form-control, + .custom-select { + background-color: $dark-surface; + border-color: $dark-border; + color: $dark-text; + } + + .input-group-text { + background-color: $dark-surface-overlay; + border-color: $dark-border; + color: $dark-text-secondary; + } + + // ===== Buttons ===== + .btn-default, + .btn-light { + background-color: $dark-surface-overlay; + border-color: $dark-border; + color: $dark-text; + + &:hover { + background-color: $dark-surface-overlay; + border-color: rgba(255, 255, 255, 0.24); + color: $dark-text; + } + } + + .btn-outline-secondary { + color: $dark-text-secondary; + border-color: $dark-border; + + &:hover { + background-color: $dark-surface-overlay; + color: $dark-text; + } + } + + .btn-link { + color: $dark-link; + + &:hover { + color: $dark-link-hover; + } + } + + // ===== Dropdowns ===== + .dropdown-menu { + background-color: $dark-surface-overlay; + border-color: $dark-border; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + } + + .dropdown-item { + color: $dark-text; + + &:hover, &:focus { + background-color: rgba(255, 255, 255, 0.08); + color: $dark-text; + } + + &.active, &:active { + background-color: rgba(125, 146, 255, 0.15); + color: $dark-link; + } + } + + .dropdown-divider { + border-top-color: $dark-border; + } + + .dropdown-header { + color: $dark-text-muted; + } + + // ===== Modals ===== + .modal-content { + background-color: $dark-surface-raised; + border-color: $dark-border; + color: $dark-text; + } + + .modal-header { + border-bottom-color: $dark-border; + } + + .modal-footer { + border-top-color: $dark-border; + } + + .modal-backdrop { + background-color: rgba(0, 0, 0, 0.6); + } + + // ===== Nav / Tabs ===== + .nav-tabs { + border-bottom-color: $dark-border; + + .nav-link { + color: $dark-text-muted; + + &:hover, &:focus { + border-color: transparent transparent $dark-border; + color: $dark-text; + } + + &.active { + background-color: transparent; + border-color: transparent transparent $dark-link; + color: $dark-text; + } + } + } + + .nav-pills .nav-link.active { + background-color: rgba(110, 168, 254, 0.15); + color: $dark-link; + } + + .tab-content { + color: $dark-text; + } + + // ===== Breadcrumbs ===== + .breadcrumb { + background-color: $dark-surface-raised; + border-color: $dark-border; + + .breadcrumb-item { + color: $dark-text-secondary; + + a { + color: $dark-link; + } + + &.active { + color: $dark-text; + } + + + .breadcrumb-item::before { + color: $dark-text-muted; + } + } + } + + // ===== List Groups ===== + .list-group-item { + background-color: $dark-surface-raised; + border-color: $dark-border; + color: $dark-text; + + &.active { + background-color: rgba(110, 168, 254, 0.12); + border-color: $dark-link; + color: $dark-text; + } + } + + .list-group-item-action { + &:hover, &:focus { + background-color: rgba(255, 255, 255, 0.06); + color: $dark-text; + } + } + + // ===== Alerts ===== + .alert { + border-left-width: 4px; + border-left-style: solid; + border-top: none; + border-right: none; + border-bottom: none; + } + + .alert-info { + background-color: rgba($dark-severity-indeterminate-border, 0.08); + border-left-color: $dark-severity-indeterminate-border; + color: $dark-text; + } + + .alert-warning { + background-color: rgba($dark-severity-warning-border, 0.08); + border-left-color: $dark-severity-warning-border; + color: $dark-text; + } + + .alert-danger { + background-color: rgba($dark-severity-critical-border, 0.08); + border-left-color: $dark-severity-critical-border; + color: $dark-text; + } + + .alert-success { + background-color: rgba($dark-severity-normal-border, 0.08); + border-left-color: $dark-severity-normal-border; + color: $dark-text; + } + + // ===== Badges ===== + .badge-light { + background-color: $dark-surface-overlay; + color: $dark-text; + } + + .badge-secondary { + background-color: rgba(255, 255, 255, 0.15); + color: $dark-text; + } + + // ===== Pagination ===== + .page-item { + .page-link { + background-color: $dark-surface-raised; + border-color: $dark-border; + color: $dark-link; + + &:hover { + background-color: $dark-surface-overlay; + border-color: $dark-border; + color: $dark-link-hover; + } + } + + &.active .page-link { + background-color: rgba(110, 168, 254, 0.15); + border-color: $dark-link; + color: $dark-text; + } + + &.disabled .page-link { + background-color: $dark-surface; + border-color: $dark-border; + color: $dark-text-muted; + } + } + + // ===== Popovers & Tooltips ===== + .popover { + background-color: $dark-surface-overlay; + border-color: $dark-border; + + .popover-header { + background-color: $dark-surface-raised; + border-bottom-color: $dark-border; + color: $dark-text; + } + + .popover-body { + color: $dark-text; + } + } + + .tooltip-inner { + background-color: $dark-surface-overlay; + color: $dark-text; + } + + // ===== Progress bars ===== + .progress { + background-color: $dark-surface; + } + + // ===== Jumbotron ===== + .jumbotron { + background-color: $dark-surface-raised; + color: $dark-text; + } + + // ===== Footer ===== + #footer.card-footer { + background-color: $dark-surface; + border-color: $dark-border; + color: $dark-text-muted; + + a { + color: $dark-link; + } + } + + // ===== Content container ===== + #content { + background-color: $dark-bg; + } + + // ===== Top nav search bar ===== + + .onms-search-input-wrapper { + background-color: $dark-surface-overlay !important; + border-color: $dark-border !important; + + .search-input { + color: $dark-text !important; + + &::placeholder { + color: $dark-text-muted !important; + } + } + } + + // ===== OpenNMS-specific components ===== + + // btn-secondary (modern UI overrides make it white, needs dark treatment) + .btn-secondary { + background-color: $dark-surface-overlay; + border-color: $dark-border; + color: $dark-text; + &:hover, &:focus { + background-color: $dark-surface-overlay; + border-color: rgba(255, 255, 255, 0.24); + color: $dark-text; + } + } + + // Labels + label, .col-form-label { + color: $dark-text-secondary; + } + + // Content boxes + .box { + background: $dark-surface-raised; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + + h2.module-title { + background-color: $dark-surface-overlay; + background-image: none; + border-bottom-color: $dark-border; + color: $dark-text; + } + } + + // Dashboard severity blocks — replace hardcoded gradients with flat dark-mode colors + .CLEARED { + background-color: $dark-severity-cleared-border !important; + background-image: none !important; + border-color: $dark-border !important; + color: #fff !important; + } + .NORMAL { + background-color: $dark-severity-normal-border !important; + background-image: none !important; + border-color: $dark-border !important; + color: #fff !important; + } + .INDETERMINATE { + background-color: $dark-severity-indeterminate-border !important; + background-image: none !important; + border-color: $dark-border !important; + color: #fff !important; + } + .WARNING { + background-color: $dark-severity-warning-border !important; + background-image: none !important; + border-color: $dark-border !important; + color: #111 !important; + } + .MINOR { + background-color: $dark-severity-minor-border !important; + background-image: none !important; + border-color: $dark-border !important; + color: #111 !important; + } + .MAJOR { + background-color: $dark-severity-major-border !important; + background-image: none !important; + border-color: $dark-border !important; + color: #fff !important; + } + .CRITICAL { + background-color: $dark-severity-critical-border !important; + background-image: none !important; + border-color: $dark-border !important; + color: #fff !important; + } + + // Node header + .nodeHeader { + background-color: $dark-surface-raised; + color: $dark-text; + } + + // Resource graph sidebar + .resource-graphs-sidebar .nav > li > a { + color: $dark-text-muted; + } + + .resource-graphs-sidebar .nav > li > a.active, + .resource-graphs-sidebar .nav > li > a:hover, + .resource-graphs-sidebar .nav > li > a:focus { + color: $dark-link; + border-left-color: $dark-link; + } + + // Dashboard severity classes (keep severity colors readable on dark bg) + .dashboard .box { + border-bottom-color: $dark-border; + + .alarm, .outage { + border-color: $dark-border; + } + } + + // Severity table row overrides for dark mode + // Need !important to beat the severity mixin's hardcoded color: black + .severity { + .severity-critical, .severity-Critical { + background-color: $dark-severity-critical-bg !important; + border-left: 3px solid $dark-severity-critical-border; + color: $dark-text !important; + background-image: none !important; + a { color: $dark-link !important; } + } + .severity-major, .severity-Major { + background-color: $dark-severity-major-bg !important; + border-left: 3px solid $dark-severity-major-border; + color: $dark-text !important; + background-image: none !important; + a { color: $dark-link !important; } + } + .severity-minor, .severity-Minor { + background-color: $dark-severity-minor-bg !important; + border-left: 3px solid $dark-severity-minor-border; + color: $dark-text !important; + background-image: none !important; + a { color: $dark-link !important; } + } + .severity-warning, .severity-Warning { + background-color: $dark-severity-warning-bg !important; + border-left: 3px solid $dark-severity-warning-border; + color: $dark-text !important; + background-image: none !important; + a { color: $dark-link !important; } + } + .severity-indeterminate, .severity-Indeterminate { + background-color: $dark-severity-indeterminate-bg !important; + border-left: 3px solid $dark-severity-indeterminate-border; + color: $dark-text !important; + background-image: none !important; + a { color: $dark-link !important; } + } + .severity-normal, .severity-Normal { + background-color: $dark-severity-normal-bg !important; + border-left: 3px solid $dark-severity-normal-border; + color: $dark-text !important; + background-image: none !important; + a { color: $dark-link !important; } + } + .severity-cleared, .severity-Cleared { + background-color: $dark-severity-cleared-bg !important; + border-left: 3px solid $dark-severity-cleared-border; + color: $dark-text !important; + background-image: none !important; + a { color: $dark-link !important; } + } + + // Also override thead.dark in severity tables + thead.dark th { + background-color: $dark-surface-overlay !important; + color: $dark-text-secondary !important; + } + } + + // Availability box + #availability-box { + color: $dark-text; + + a { color: $dark-link !important; } + + .percent { + color: $dark-text !important; + } + + // Timeline header/bar PNGs are server-rendered with black text. + // Invert them so black text becomes white on dark backgrounds. + .interface.header img, + .service.timeline img, + .service.timeline span img { + filter: invert(1) hue-rotate(180deg); + } + + // Timeline HTML fragments (loaded via data-src) also need inverted text + .service.timeline span { + filter: invert(1) hue-rotate(180deg); + } + } + + // ── C3.js charts (Status Overview donut charts) ──────────────────── + .c3 text { + fill: $dark-text !important; + } + + .c3-tooltip { + background-color: $dark-surface-overlay !important; + color: $dark-text !important; + border-color: $dark-border !important; + + th { + background-color: $dark-surface-raised !important; + color: $dark-text !important; + } + + td { + background-color: $dark-surface-overlay !important; + color: $dark-text !important; + border-color: $dark-border !important; + } + } + + .c3-legend-background { + fill: $dark-surface-raised !important; + stroke: $dark-border !important; + } + + // ── Backshift / Flot graph containers ────────────────────────────── + .graph-container, + .graph-aux-controls { + color: $dark-text; + } + + #graph-results .card { + border-color: $dark-border !important; + box-shadow: none; + } + + .graph-aux-controls { + background-color: $dark-surface-raised !important; + border-color: $dark-border !important; + } + + #graph-results .form-group.form-row { + background: $dark-surface-raised !important; + border-color: $dark-border !important; + color: $dark-text; + } + + // Flot renders tick labels as absolutely-positioned DOM divs — CSS works here. + .flot-tick-label, + .flot-x-axis .flot-tick-label, + .flot-y-axis .flot-tick-label { + color: $dark-text-secondary !important; + } + + // Flot legend: the outer table has a hardcoded white bgcolor via inline style + .legend > div { + background-color: $dark-surface-raised !important; + border-color: $dark-border !important; + } + .legend table { + color: $dark-text !important; + } + .legendLabel { + color: $dark-text !important; + } + + // flot-datatable "Data" panel — the plugin hard-codes background:white as an + // inline style, so !important is required to win specificity. + .flot-datatable-data { + background: $dark-surface !important; + color: $dark-text !important; + border: 1px solid $dark-border !important; + + table { + color: $dark-text !important; + + th { + background: $dark-surface-raised !important; + color: $dark-text !important; + border-color: $dark-border !important; + } + + td { + color: $dark-text !important; + border-color: $dark-border !important; + } + + tr:hover td { + background: $dark-surface-raised !important; + } + } + + input[type="checkbox"] { + accent-color: $dark-link; + } + } + + // flot-datatable tab strip (Graph / Data) + .flot-datatable-tabs { + background: $dark-surface-raised !important; + border-bottom: 1px solid $dark-border !important; + } + .flot-datatable-tab { + color: $dark-text-secondary !important; + background: $dark-surface-raised !important; + border-color: $dark-border !important; + + &:hover { + color: $dark-text !important; + background: $dark-surface-overlay !important; + } + } + + // Vaadin iframe pages + iframe.vaadin-fullscreen { + border-color: $dark-border; + } + + // Close buttons + .close { + color: $dark-text; + text-shadow: none; + opacity: 0.7; + + &:hover { + color: $dark-text; + opacity: 1; + } + } + + // HR + hr { + border-top-color: $dark-border; + } + + // Code blocks + code { + background: rgba(110, 168, 254, 0.10); + color: $dark-link; + } + + pre { + background-color: $dark-bg; + border-color: $dark-border; + color: $dark-text-secondary; + + code { + background: transparent; + color: inherit; + } + } + + kbd { + background-color: $dark-surface-overlay; + color: $dark-text; + } + + // Icon color override + .icon-black { + color: $dark-text; + } + + // Bootbox modal overrides + .bootbox .modal-header h4 { + color: $dark-text; + } + + // Scrollbar styling (Webkit browsers) + ::-webkit-scrollbar { + width: 10px; + height: 10px; + } + + ::-webkit-scrollbar-track { + background: $dark-surface; + } + + ::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.2); + border-radius: 5px; + + &:hover { + background: rgba(255, 255, 255, 0.3); + } + } + + // Firefox scrollbar styling + scrollbar-color: rgba(255, 255, 255, 0.2) $dark-surface; + + // ── Geo Map / Leaflet ────────────────────────────────────────────── + .leaflet-container { + background: $dark-surface; + } + + .leaflet-popup-content-wrapper, + .leaflet-popup-tip { + background: $dark-surface-raised; + color: $dark-text; + } + + .leaflet-popup-content-wrapper { + border: 1px solid $dark-border; + } + + .leaflet-control-zoom a, + .leaflet-control-layers, + .leaflet-bar a { + background-color: $dark-surface-raised !important; + color: $dark-text !important; + border-color: $dark-border !important; + } + + .leaflet-control-zoom a:hover, + .leaflet-bar a:hover { + background-color: $dark-surface-overlay !important; + } + + .leaflet-control-attribution { + background: rgba(26, 26, 26, 0.8) !important; + color: $dark-text-muted !important; + + a { + color: $dark-link !important; + } + } + + .geomap .leaflet-container .leaflet-control .selected { + background-color: $dark-surface-overlay !important; + } + + .geomap table.node-marker-list { + color: $dark-text; + + td, th { + border-color: $dark-border; + } + } + + // Geomap severity popups — ensure white text is readable on dark popup bg + .geomap .severity { + border-radius: 3px; + } + + .geomap .severity a { + color: #fff !important; + } + + .geomap .marker-cluster div { + color: #fff !important; + } + + // Invert map tiles for dark mode (keeps colors recognizable, darkens whites). + // Only the base tile layer is inverted — marker/overlay panes get a counter-filter + // to undo the inversion and preserve severity color semantics (red=critical, green=normal). + .leaflet-tile-pane { + filter: invert(1) hue-rotate(180deg) brightness(0.9) contrast(0.9); + } + + // Counter-invert marker, overlay, and popup panes so severity colors are NOT corrupted. + // Red stays red (critical), green stays green (normal), etc. + .leaflet-overlay-pane, + .leaflet-marker-pane, + .leaflet-shadow-pane, + .leaflet-popup-pane, + .leaflet-tooltip-pane { + filter: invert(1) hue-rotate(180deg) brightness(1.11) contrast(1.11); + } + + // ── AngularJS interface status row backgrounds ───────────────────── + .onms-interface-status-up { + background: rgba(76, 157, 69, 0.15) !important; + color: $dark-text; + } + .onms-interface-status-down { + background: rgba(239, 68, 68, 0.15) !important; + color: $dark-text; + } + .onms-interface-status-unknown { + background: rgba(110, 168, 254, 0.15) !important; + color: $dark-text; + } + .onms-interface-status-indeterminate { + background: rgba(246, 206, 205, 0.15) !important; + color: $dark-text; + } + + // ── AngularJS UI Bootstrap tabs (uib-tabset) ────────────────────── + .nav-tabs > li > a, + [uib-tabset] .nav-tabs > li > a { + color: $dark-text-muted !important; + background-color: transparent !important; + border-color: transparent !important; + + &:hover, &:focus { + color: $dark-text !important; + border-color: transparent transparent $dark-border !important; + background-color: transparent !important; + } + } + + .nav-tabs > li.active > a, + .nav-tabs > li > a.active, + [uib-tabset] .nav-tabs > li.active > a { + color: $dark-text !important; + background-color: transparent !important; + border-color: transparent transparent $dark-link !important; + } + + // ── Pagination ───────────────────────────────────────────────────── + .pagination .page-link, + .page-link { + background-color: $dark-surface; + border-color: $dark-border; + color: $dark-link; + } + + .pagination .page-item.active .page-link, + .page-item.active .page-link { + background-color: rgba(110, 168, 254, 0.2); + border-color: $dark-link; + color: $dark-link; + } + + .pagination .page-item.disabled .page-link, + .page-item.disabled .page-link { + background-color: $dark-surface-raised; + border-color: $dark-border; + color: $dark-text-muted; + } + + // ── Catch-all: force any remaining bare white backgrounds ───────── + .container-fluid, + .container, + .row, + .col, [class*="col-"] { + background-color: transparent; + } + + .table-responsive { + background-color: transparent; + } + + // List groups + .list-group-item { + background-color: $dark-surface-raised; + border-color: $dark-border; + color: $dark-text; + + &.active { + background-color: rgba(110, 168, 254, 0.15); + border-color: $dark-link; + color: $dark-link; + } + } + + // Popovers + .popover { + background-color: $dark-surface-overlay; + border-color: $dark-border; + color: $dark-text; + + .popover-header { + background-color: $dark-surface-raised; + border-bottom-color: $dark-border; + color: $dark-text; + } + + .popover-body { + color: $dark-text; + } + + .arrow::before { + border-color: $dark-border; + } + .arrow::after { + border-color: $dark-surface-overlay; + } + } + + // Tooltip + .tooltip-inner { + background-color: $dark-surface-overlay; + color: $dark-text; + } + + // Badge overrides + .badge-secondary { + background-color: $dark-surface-overlay; + color: $dark-text; + } + + .badge-light { + background-color: $dark-surface-raised; + color: $dark-text; + } + + // =================================================================== + // Vaadin 8 Dark Mode Overrides + // =================================================================== + // Vaadin pages (Surveillance Views, Topology, Dashboard, BSM Admin, + // JMX Config Generator) use the Reindeer/Runo legacy theme with + // hardcoded light colors. These overrides make them honor dark mode. + + // ── NUCLEAR REINDEER RESET ─────────────────────────────────────── + // The Reindeer legacy-styles.css sets white/light backgrounds, gradient + // images, and text-shadows on nearly every widget. This broad reset + // kills them in one shot so individual overrides below can set dark values + // cleanly without fighting specificity. + [class*="v-"] { + text-shadow: none !important; + } + + .v-ui, + .v-app, + .v-verticallayout, + .v-horizontallayout, + .v-orderedlayout, + .v-gridlayout, + .v-csslayout, + .v-customlayout, + .v-customcomponent, + .v-absolutelayout, + .v-absolutelayout-wrapper, + .v-formlayout, + .v-formlayout-row, + .v-formlayout-contentcell, + .v-formlayout-captioncell, + .v-formlayout-errorcell, + .v-slot, + .v-expand, + .v-spacing, + .v-margin-top, + .v-margin-bottom, + .v-margin-left, + .v-margin-right { + background-color: transparent !important; + background-image: none !important; + color: $dark-text !important; + } + + // ── Core Vaadin UI background ────────────────────────────────────── + .v-ui { + background: $dark-bg !important; + color: $dark-text !important; + } + + .v-app { + background: $dark-bg !important; + color: $dark-text !important; + } + + // Generated body (standalone Vaadin pages) + &.v-generated-body { + background: $dark-bg !important; + } + + // ── Vaadin Panels ────────────────────────────────────────────────── + .v-panel { + background: $dark-surface !important; + border-color: $dark-border !important; + } + + .v-panel-content { + background: $dark-surface !important; + color: $dark-text !important; + } + + .v-panel-caption, + .v-panel-caption-light, + .v-panel-caption-novscroll { + background: $dark-surface-raised !important; + color: $dark-text !important; + border-color: $dark-border !important; + } + + .v-panel-deco { + background: transparent !important; + } + + // ── Vaadin Tables ────────────────────────────────────────────────── + .v-table { + background: $dark-surface !important; + color: $dark-text !important; + } + + .v-table-header-wrap, + .v-table-header { + background: $dark-surface-raised !important; + color: $dark-text-secondary !important; + } + + .v-table-header-cell { + border-color: $dark-border !important; + background: $dark-surface-raised !important; + color: $dark-text-secondary !important; + } + + .v-table-caption-container { + color: $dark-text-secondary !important; + } + + .v-table-body { + background: $dark-surface !important; + border-color: $dark-border !important; + } + + .v-table-row, + .v-table-row-odd { + background: $dark-surface !important; + color: $dark-text !important; + + &:hover { + background: rgba(255, 255, 255, 0.04) !important; + } + } + + .v-table-row-odd { + background: rgba(255, 255, 255, 0.02) !important; + } + + .v-table-cell-content { + border-color: $dark-border !important; + color: $dark-text !important; + } + + .v-table-cell-wrapper { + color: $dark-text !important; + } + + .v-table-resizer { + border-color: $dark-border !important; + } + + .v-table-focus { + outline-color: $dark-link !important; + } + + .v-table-column-selector { + background: $dark-surface-overlay !important; + color: $dark-text !important; + } + + // ── Vaadin Table Alarm Severity Rows (dark mode) ─────────────────── + .v-table-row-alarm-critical, + .v-table-row-alarm-critical-noack { + background-color: $dark-severity-critical-bg !important; + border-top-color: $dark-severity-critical-border !important; + color: $dark-text !important; + } + .v-table-row-alarm-critical.v-table-row-odd, + .v-table-row-alarm-critical-noack.v-table-row-odd { + background-color: rgba(239, 68, 68, 0.15) !important; + } + + .v-table-row-alarm-major, + .v-table-row-alarm-major-noack { + background-color: $dark-severity-major-bg !important; + border-top-color: $dark-severity-major-border !important; + color: $dark-text !important; + } + .v-table-row-alarm-major.v-table-row-odd, + .v-table-row-alarm-major-noack.v-table-row-odd { + background-color: rgba(249, 115, 22, 0.15) !important; + } + + .v-table-row-alarm-minor, + .v-table-row-alarm-minor-noack { + background-color: $dark-severity-minor-bg !important; + border-top-color: $dark-severity-minor-border !important; + color: $dark-text !important; + } + .v-table-row-alarm-minor.v-table-row-odd, + .v-table-row-alarm-minor-noack.v-table-row-odd { + background-color: rgba(245, 158, 11, 0.14) !important; + } + + .v-table-row-alarm-warning, + .v-table-row-alarm-warning-noack { + background-color: $dark-severity-warning-bg !important; + border-top-color: $dark-severity-warning-border !important; + color: $dark-text !important; + } + .v-table-row-alarm-warning.v-table-row-odd, + .v-table-row-alarm-warning-noack.v-table-row-odd { + background-color: rgba(234, 179, 8, 0.14) !important; + } + + .v-table-row-alarm-indeterminate, + .v-table-row-alarm-indeterminate-noack { + background-color: $dark-severity-indeterminate-bg !important; + border-top-color: $dark-severity-indeterminate-border !important; + color: $dark-text !important; + } + .v-table-row-alarm-indeterminate.v-table-row-odd, + .v-table-row-alarm-indeterminate-noack.v-table-row-odd { + background-color: rgba(59, 130, 246, 0.14) !important; + } + + .v-table-row-alarm-normal, + .v-table-row-alarm-normal-noack { + background-color: $dark-severity-normal-bg !important; + border-top-color: $dark-severity-normal-border !important; + color: $dark-text !important; + } + .v-table-row-alarm-normal.v-table-row-odd, + .v-table-row-alarm-normal-noack.v-table-row-odd { + background-color: rgba(34, 197, 94, 0.14) !important; + } + + .v-table-row-alarm-cleared, + .v-table-row-alarm-cleared-noack { + background-color: $dark-severity-cleared-bg !important; + border-top-color: $dark-severity-cleared-border !important; + color: $dark-text !important; + } + .v-table-row-alarm-cleared.v-table-row-odd, + .v-table-row-alarm-cleared-noack.v-table-row-odd { + background-color: rgba(156, 163, 175, 0.10) !important; + } + + // Kill the severity background images in dark mode + .v-table-cell-content-bright { + background-image: none !important; + } + + // ── Vaadin Buttons ───────────────────────────────────────────────── + .v-button { + background: $dark-surface-overlay !important; + background-image: none !important; + border-color: $dark-border !important; + color: $dark-text !important; + text-shadow: none !important; + box-shadow: none !important; + + &:hover, + &:focus { + background: rgba(255, 255, 255, 0.10) !important; + } + } + + .v-button-wrap { + background: transparent !important; + background-image: none !important; + color: $dark-text !important; + } + + .v-button-caption { + color: $dark-text !important; + } + + .v-button-caption-bold { + color: $dark-text !important; + } + + // ── Vaadin Links ─────────────────────────────────────────────────── + .v-link a span, + .v-link a:link span, + .v-link a:visited span { + color: $dark-link !important; + } + + .v-link a:hover span { + color: $dark-link-hover !important; + } + + // ── Vaadin Forms ─────────────────────────────────────────────────── + .v-textfield, + .v-textarea, + .v-richtextarea { + background: $dark-surface !important; + border-color: $dark-border !important; + color: $dark-text !important; + + &:focus { + border-color: $dark-link !important; + } + } + + .v-select-select, + .v-filterselect-input { + background: $dark-surface !important; + border-color: $dark-border !important; + color: $dark-text !important; + } + + .v-filterselect-button { + background: $dark-surface-overlay !important; + border-color: $dark-border !important; + } + + .v-filterselect-suggestpopup { + background: $dark-surface-overlay !important; + border-color: $dark-border !important; + color: $dark-text !important; + } + + .v-checkbox > label, + .v-radiobutton > label { + color: $dark-text !important; + } + + // ── Vaadin Labels ────────────────────────────────────────────────── + .v-label { + color: $dark-text !important; + } + + .v-caption { + color: $dark-text-secondary !important; + } + + // ── Vaadin MenuBar ───────────────────────────────────────────────── + .v-menubar { + background: $dark-surface-raised !important; + background-image: none !important; + border-color: $dark-border !important; + color: $dark-text !important; + } + + .v-menubar-menuitem { + color: $dark-text !important; + + &:hover { + background: rgba(255, 255, 255, 0.08) !important; + } + } + + .v-menubar-menuitem-selected { + background: rgba(110, 168, 254, 0.15) !important; + color: $dark-link !important; + } + + .v-menubar-submenu { + background: $dark-surface-overlay !important; + border-color: $dark-border !important; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4) !important; + } + + .v-menubar-submenu .v-menubar-menuitem { + color: $dark-text !important; + + &:hover { + background: rgba(255, 255, 255, 0.08) !important; + } + } + + // ── Vaadin TabSheet ──────────────────────────────────────────────── + .v-tabsheet-tabcontainer { + background: $dark-surface-raised !important; + border-color: $dark-border !important; + } + + .v-tabsheet-tabitem .v-caption { + color: $dark-text-muted !important; + background: transparent !important; + } + + .v-tabsheet-tabitem-selected .v-caption { + color: $dark-link !important; + border-bottom-color: $dark-link !important; + } + + .v-tabsheet-content { + background: $dark-surface !important; + border-color: $dark-border !important; + } + + .v-tabsheet-deco { + background: $dark-border !important; + } + + // ── Vaadin Windows / Dialogs ─────────────────────────────────────── + .v-window { + background: $dark-surface-raised !important; + border-color: $dark-border !important; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5) !important; + } + + .v-window-header { + color: $dark-text !important; + } + + .v-window-contents { + background: $dark-surface !important; + color: $dark-text !important; + } + + .v-window-outerheader, + .v-window-header { + background: $dark-surface-overlay !important; + color: $dark-text !important; + } + + .v-window-footer { + background: $dark-surface-overlay !important; + border-top-color: $dark-border !important; + } + + // ── Vaadin Accordion ─────────────────────────────────────────────── + .v-accordion-item-caption { + background: $dark-surface-raised !important; + background-image: none !important; + border-color: $dark-border !important; + color: $dark-text !important; + } + + .v-accordion-item-content { + background: $dark-surface !important; + color: $dark-text !important; + } + + // ── Vaadin Notifications ─────────────────────────────────────────── + .v-Notification { + background: $dark-surface-overlay !important; + border-color: $dark-border !important; + color: $dark-text !important; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4) !important; + + h1, .v-Notification-caption { + color: $dark-text !important; + } + + p, .v-Notification-description { + color: $dark-text-secondary !important; + } + } + + // ── Vaadin Tree ──────────────────────────────────────────────────── + .v-tree-node { + color: $dark-text !important; + } + + .v-tree-node-caption { + color: $dark-text !important; + + &:hover { + background: rgba(255, 255, 255, 0.04) !important; + } + } + + .v-tree-node-selected { + background: rgba(110, 168, 254, 0.15) !important; + color: $dark-link !important; + } + + // ── Vaadin Scrollbar / Layout ────────────────────────────────────── + .v-scrollable { + background: $dark-surface !important; + } + + .v-absolutelayout-wrapper { + color: $dark-text !important; + } + + .v-csslayout { + color: $dark-text !important; + } + + .v-verticallayout, + .v-horizontallayout, + .v-gridlayout { + color: $dark-text !important; + } + + // ── Vaadin Loading Indicator ─────────────────────────────────────── + .v-loading-indicator { + background-color: $dark-link !important; + } + + // ── Surveillance View (dark mode) ────────────────────────────────── + .surveillance-view .v-table-cell-content { + background-color: $dark-surface !important; + color: $dark-text !important; + } + + .surveillance-view .white, + .surveillance-view .v-table-cell-content-white { + color: $dark-text !important; + background-color: $dark-surface !important; + border-top-color: $dark-border !important; + } + + .surveillance-view .critical, + .surveillance-view .v-table-cell-content-critical, + .surveillance-view .v-table-cell-content-critical-image { + color: $dark-text !important; + background-color: $dark-severity-critical-bg !important; + border-top-color: $dark-severity-critical-border !important; + background-image: none !important; + } + + .surveillance-view .major, + .surveillance-view .v-table-cell-content-major, + .surveillance-view .v-table-cell-content-major-image { + color: $dark-text !important; + background-color: $dark-severity-major-bg !important; + border-top-color: $dark-severity-major-border !important; + background-image: none !important; + } + + .surveillance-view .minor, + .surveillance-view .v-table-cell-content-minor, + .surveillance-view .v-table-cell-content-minor-image { + color: $dark-text !important; + background-color: $dark-severity-minor-bg !important; + border-top-color: $dark-severity-minor-border !important; + background-image: none !important; + } + + .surveillance-view .warning, + .surveillance-view .v-table-cell-content-warning, + .surveillance-view .v-table-cell-content-warning-image { + color: $dark-text !important; + background-color: $dark-severity-warning-bg !important; + border-top-color: $dark-severity-warning-border !important; + background-image: none !important; + } + + .surveillance-view .indeterminate, + .surveillance-view .v-table-cell-content-indeterminate, + .surveillance-view .v-table-cell-content-indeterminate-image { + color: $dark-text !important; + background-color: $dark-severity-indeterminate-bg !important; + border-top-color: $dark-severity-indeterminate-border !important; + background-image: none !important; + } + + .surveillance-view .normal, + .surveillance-view .v-table-cell-content-normal, + .surveillance-view .v-table-cell-content-normal-image, + .surveillance-view .v-table-cell-content-rtc-normal { + color: $dark-text !important; + background-color: $dark-severity-normal-bg !important; + border-top-color: $dark-severity-normal-border !important; + background-image: none !important; + } + + .surveillance-view .cleared, + .surveillance-view .v-table-cell-content-cleared, + .surveillance-view .v-table-cell-content-cleared-image { + color: $dark-text !important; + background-color: $dark-severity-cleared-bg !important; + border-top-color: $dark-severity-cleared-border !important; + background-image: none !important; + } + + .surveillance-view .v-table-cell-content-rtc-critical { + color: $dark-text !important; + background-color: $dark-severity-critical-bg !important; + border-top-color: $dark-severity-critical-border !important; + background-image: none !important; + } + + .v-caption-surveillance-view { + background-color: $dark-surface-overlay !important; + border-color: $dark-border !important; + color: $dark-text !important; + } + + .surveillance-view .link { + color: $dark-link !important; + } + + // ── Dashboard / Ops Board (dark mode) ────────────────────────────── + .dashboard.v-app { + background: $dark-bg !important; + background-image: none !important; + } + + .dashboard .v-portlet-slot { + background: $dark-surface !important; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3) !important; + } + + .dashboard .v-portlet-header { + background: $dark-surface-overlay !important; + background-image: none !important; + color: $dark-text !important; + text-shadow: none !important; + } + + .dashboard .v-portlet-content-wrapper { + background: $dark-surface !important; + border-color: $dark-border !important; + } + + .dashboard .header { + background-color: $dark-surface-raised !important; + border-color: $dark-border !important; + } + + .dashboard .wallboard-board { + color: $dark-link !important; + background: $dark-surface !important; + } + + .dashboard .wallboard-board .progress-paused { + background: rgba(249, 115, 22, 0.2) !important; + border-color: $dark-severity-major-border !important; + color: $dark-text !important; + } + + .dashboard .v-panel-caption, + .dashboard .v-panel-caption-light { + color: $dark-text !important; + background: $dark-surface-raised !important; + background-image: none !important; + } + + // Dashboard config UI + .configuration-title, + .help-title { + color: $dark-text !important; + } + + .help-content { + color: $dark-text-secondary !important; + } + + .help-table { + border-color: $dark-border !important; + color: $dark-text !important; + } + + .help-table-cell { + background: $dark-surface !important; + color: $dark-text !important; + } + + // Panel captions in dashboard context + .novscroll, + .v-panel-content-novscroll { + background: $dark-surface !important; + } + + // Dashboard severity classes + .severity { + color: $dark-text !important; + background-image: none !important; + + &.Critical { + background-color: $dark-severity-critical-bg !important; + border-top-color: $dark-severity-critical-border !important; + background-image: none !important; + } + &.Major { + background-color: $dark-severity-major-bg !important; + border-top-color: $dark-severity-major-border !important; + background-image: none !important; + } + &.Minor { + background-color: $dark-severity-minor-bg !important; + border-top-color: $dark-severity-minor-border !important; + background-image: none !important; + } + &.Warning { + background-color: $dark-severity-warning-bg !important; + border-top-color: $dark-severity-warning-border !important; + background-image: none !important; + } + &.Indeterminate { + background-color: $dark-severity-indeterminate-bg !important; + border-top-color: $dark-severity-indeterminate-border !important; + background-image: none !important; + } + &.Normal { + background-color: $dark-severity-normal-bg !important; + border-top-color: $dark-severity-normal-border !important; + background-image: none !important; + } + &.Cleared { + background-color: $dark-severity-cleared-bg !important; + border-top-color: $dark-severity-cleared-border !important; + background-image: none !important; + } + } + + // Alert details dashlet + .alert-details-dashlet.onms-table { + color: $dark-text !important; + } + + .alert-details-dashlet .onms-header-cell { + background-color: $dark-surface-overlay !important; + border-color: $dark-border !important; + color: $dark-text !important; + } + + .alert-details-dashlet .onms-cell { + background-color: $dark-surface !important; + border-color: $dark-border !important; + color: $dark-text !important; + } + + .alert-details-dashlet .Critical { + background-color: $dark-severity-critical-bg !important; + background-image: none !important; + } + .alert-details-dashlet .Major { + background-color: $dark-severity-major-bg !important; + background-image: none !important; + } + .alert-details-dashlet .Minor { + background-color: $dark-severity-minor-bg !important; + background-image: none !important; + } + .alert-details-dashlet .Warning { + background-color: $dark-severity-warning-bg !important; + background-image: none !important; + } + .alert-details-dashlet .Indeterminate { + background-color: $dark-severity-indeterminate-bg !important; + background-image: none !important; + } + .alert-details-dashlet .Normal { + background-color: $dark-severity-normal-bg !important; + background-image: none !important; + } + .alert-details-dashlet .Cleared { + background-color: $dark-severity-cleared-bg !important; + background-image: none !important; + } + + // ── Topology Map (dark mode) ─────────────────────────────────────── + .v-app.topo_default { + background: $dark-bg !important; + color: $dark-text !important; + } + + .map-content { + background-color: $dark-bg !important; + border-left-color: $dark-border !important; + } + + .info-panel-component { + background-color: $dark-surface !important; + color: $dark-text !important; + } + + .info-panel-area { + background-color: $dark-surface !important; + } + + .info-panel-item-label { + color: $dark-text !important; + } + + .toolbar { + background-color: $dark-surface !important; + border-left-color: $dark-border !important; + } + + .toolbar .toolbar-component-group { + background-color: $dark-surface-raised !important; + color: $dark-text-secondary !important; + } + + .toolbar .toolbar-group-item { + background-image: none !important; + color: $dark-text-secondary !important; + + &:hover { + color: $dark-link !important; + } + + &.selected, + &.selected:hover { + background-color: rgba(110, 168, 254, 0.2) !important; + color: $dark-link !important; + } + } + + .toolbar .layout.selected { + background-color: rgba(110, 168, 254, 0.2) !important; + color: $dark-link !important; + } + + .toggle-button { + background-color: $dark-surface-overlay !important; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.4) !important; + filter: invert(1) !important; + } + + // Topology HUD display + .topoHudDisplay { + border-color: $dark-border !important; + } + + .topoHudDisplay td, + .topoHudDisplay th { + background-color: $dark-surface !important; + border-color: $dark-border !important; + color: $dark-text !important; + } + + // Topology SVG labels and elements + .vertex-label { + fill: $dark-text !important; + } + + .vertex .navigate-to { + fill: $dark-text !important; + } + + .vertex .navigate-to .text { + fill: $dark-text-secondary !important; + stroke: $dark-surface !important; + } + + .edge { + stroke: $dark-border !important; + } + + // GWT SuggestBox (topology search) + .gwt-SuggestBoxPopup { + background: $dark-surface-overlay !important; + border-color: $dark-border !important; + color: $dark-text !important; + } + + .gwt-SuggestBoxPopup .suggestPopupContent { + background: $dark-surface-overlay !important; + } + + .gwt-SuggestBoxPopup .item { + color: $dark-text !important; + } + + .gwt-SuggestBoxPopup .item-selected { + background: rgba(110, 168, 254, 0.15) !important; + color: $dark-link !important; + } + + // Search token styling + .search-token-field { + color: $dark-text !important; + background-color: $dark-surface-raised !important; + } + + // Topology threshold dialog + .threshold .v-table-cell-content-selected { + background-color: rgba(110, 168, 254, 0.2) !important; + } + + // Icon selection dialog + .icon-selection-component { + background-color: $dark-surface !important; + } + + .svgIconOverlay.selected { + stroke: $dark-link !important; + } + + // ── BSM Admin greyed rows ────────────────────────────────────────── + .v-table-cell-content-grey { + opacity: 0.4 !important; + } + + // ── Vaadin Select / NativeSelect ─────────────────────────────────── + .v-select { + .v-select-select { + background: $dark-surface !important; + border-color: $dark-border !important; + color: $dark-text !important; + } + } + + // ── Vaadin Toolbar / Layout backgrounds ──────────────────────────── + .v-slot, + .v-expand { + color: $dark-text !important; + } + + // ── Vaadin SplitPanel (dark mode) ───────────────────────────────── + .v-splitpanel-horizontal, + .v-splitpanel-vertical { + background: $dark-surface !important; + } + + .v-splitpanel-hsplitter, + .v-splitpanel-vsplitter { + background: $dark-border !important; + background-image: none !important; + + div { + background: transparent !important; + background-image: none !important; + } + } + + // ── Vaadin PopupView (dark mode) ────────────────────────────────── + .v-popupview-popup { + background: $dark-surface-overlay !important; + border-color: $dark-border !important; + color: $dark-text !important; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4) !important; + } + + // ── Vaadin DateField (dark mode) ────────────────────────────────── + .v-datefield [class*="textfield"] { + background: $dark-surface !important; + border-color: $dark-border !important; + color: $dark-text !important; + } + + .v-datefield [class*="button"] { + background: $dark-surface-overlay !important; + background-image: none !important; + border-color: $dark-border !important; + } + + .v-datefield-calendarpanel { + background: $dark-surface-overlay !important; + border-color: $dark-border !important; + color: $dark-text !important; + } + + .v-datefield-calendarpanel-day { + color: $dark-text !important; + + &:hover { + background: rgba(255, 255, 255, 0.08) !important; + } + } + + .v-datefield-calendarpanel-day-selected { + background: $dark-link !important; + color: #fff !important; + } + + .v-datefield-calendarpanel-day-offmonth { + color: $dark-text-muted !important; + } + + // ── Vaadin Tooltip (dark mode) ──────────────────────────────────── + .v-tooltip { + background: $dark-surface-overlay !important; + border-color: $dark-border !important; + color: $dark-text !important; + } + + .v-tooltip .v-tooltip-text { + color: $dark-text !important; + } + + // ── Vaadin ContextMenu (dark mode) ──────────────────────────────── + .v-contextmenu { + background: $dark-surface-overlay !important; + border-color: $dark-border !important; + color: $dark-text !important; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4) !important; + } + + .v-contextmenu .v-contextmenu-menuitem { + color: $dark-text !important; + + &:hover { + background: rgba(255, 255, 255, 0.08) !important; + } + } + + // ── Vaadin Overlay / Backdrop (dark mode) ───────────────────────── + .v-window-modalitycurtain { + background: rgba(0, 0, 0, 0.6) !important; + } + + // ── Nuclear Reindeer gradient + text-shadow kill (dark mode) ────── + // Ensure ALL Vaadin elements with Reindeer gradients are neutralized + .v-panel-caption, + .v-panel-caption-light, + .v-table-header-cell, + .v-table-column-selector, + .v-button, + .v-button-wrap, + .v-nativebutton, + .v-menubar, + .v-menubar-menuitem, + .v-tabsheet-tabitem .v-caption, + .v-tabsheet-tabcontainer, + .v-accordion-item-caption, + .v-window-outerheader, + .v-window-header, + .v-window-footer, + .v-filterselect-button, + .v-datefield [class*="button"], + .v-splitpanel-hsplitter div, + .v-splitpanel-vsplitter div, + .v-Notification { + background-image: none !important; + text-shadow: none !important; + } + + // ── Topology Map (dark mode extended) ───────────────────────────── + .v-app.topo_default .v-csslayout, + .v-app.topo_default .v-verticallayout, + .v-app.topo_default .v-horizontallayout { + background: transparent !important; + color: $dark-text !important; + } + + // ── Vaadin NativeButton (dark mode) ─────────────────────────────── + .v-nativebutton { + background: $dark-surface-overlay !important; + background-image: none !important; + border-color: $dark-border !important; + color: $dark-text !important; + + &:hover { + background: rgba(255, 255, 255, 0.10) !important; + } + } + + // ── Vaadin ProgressBar (dark mode) ──────────────────────────────── + .v-progressbar-wrapper { + background: $dark-surface !important; + border-color: $dark-border !important; + } + + .v-progressbar-indicator { + background: $dark-link !important; + background-image: none !important; + } + + // ── Vaadin Slider (dark mode) ───────────────────────────────────── + .v-slider-base { + background: $dark-surface !important; + border-color: $dark-border !important; + } + + .v-slider-handle { + background: $dark-link !important; + background-image: none !important; + border-color: $dark-link !important; + } + + // ── Generated body backgrounds (dark mode) ──────────────────────── + .v-verticallayout, + .v-horizontallayout, + .v-gridlayout, + .v-csslayout, + .v-absolutelayout-wrapper, + .v-formlayout, + .v-formlayout-row, + .v-formlayout-contentcell, + .v-formlayout-captioncell, + .v-formlayout-errorcell { + background: transparent !important; + color: $dark-text !important; + } + + // ── Vaadin Scrollable (dark mode) ───────────────────────────────── + .v-scrollable::-webkit-scrollbar { + width: 8px; + height: 8px; + } + + .v-scrollable::-webkit-scrollbar-track { + background: $dark-surface !important; + } + + .v-scrollable::-webkit-scrollbar-thumb { + background: $dark-border !important; + border-radius: 4px; + } + + // ── Vaadin ComboBox / FilterSelect popup (dark mode) ────────────── + .v-filterselect-suggestmenu { + background: $dark-surface-overlay !important; + border-color: $dark-border !important; + color: $dark-text !important; + } + + .v-filterselect-suggestmenu .gwt-MenuItem { + color: $dark-text !important; + + &:hover, + &.gwt-MenuItem-selected { + background: rgba(110, 168, 254, 0.15) !important; + color: $dark-link !important; + } + } + + .v-filterselect-status { + background: $dark-surface !important; + border-color: $dark-border !important; + color: $dark-text-muted !important; + } + + // ── Vaadin NativeSelect (dark mode) ─────────────────────────────── + .v-nativeselect select { + background: $dark-surface !important; + border-color: $dark-border !important; + color: $dark-text !important; + } + + // ── Vaadin Upload (dark mode) ───────────────────────────────────── + .v-upload { + .v-button { + background: $dark-surface-overlay !important; + color: $dark-text !important; + } + } + + // ── Vaadin Embedded / Image ─────────────────────────────────────── + .v-image, + .v-embedded { + border-color: $dark-border !important; + } + + // ── Reindeer-specific component backgrounds ─────────────────────── + // The Reindeer theme sets explicit white/light backgrounds on these + // elements via legacy-styles.css. Override them all. + .v-panel-content-light, + .v-panel-light { + background: $dark-surface !important; + color: $dark-text !important; + } + + .v-tabsheet-tabsheetpanel { + background: $dark-surface !important; + } + + .v-tabsheet-tabs td { + background: transparent !important; + } + + .v-tabsheet-scroller { + background: $dark-surface-raised !important; + + button { + color: $dark-text !important; + filter: invert(1); + } + } + + // ── Vaadin Error/Required indicators ────────────────────────────── + .v-errorindicator { + color: $dark-severity-critical-border !important; + } + + .v-required-field-indicator { + color: $dark-severity-critical-border !important; + } + + // ── Vaadin Info Panel (Topology sidebar) ────────────────────────── + .v-panel-info-panel, + .info-panel { + background: $dark-surface !important; + border-color: $dark-border !important; + color: $dark-text !important; + } + + .info-panel .v-label { + color: $dark-text !important; + } + + // ── Vaadin Toolbar (Topology) ───────────────────────────────────── + .toolbar { + background: $dark-surface-raised !important; + border-color: $dark-border !important; + } + + .toolbar .v-button { + background: transparent !important; + border-color: transparent !important; + color: $dark-text !important; + + &:hover { + background: rgba(255, 255, 255, 0.08) !important; + } + } + + // ── Vaadin Search Box / breadcrumb (Topology) ───────────────────── + .search-combobox { + .v-filterselect-input { + background: $dark-surface !important; + border-color: $dark-border !important; + color: $dark-text !important; + } + } + + .v-breadcrumb { + background: transparent !important; + color: $dark-text-secondary !important; + } + + // ── Confirmation/Alert dialogs ──────────────────────────────────── + .v-window-modal-window, + .v-window-confirm-dialog { + background: $dark-surface-raised !important; + + .v-window-contents { + background: $dark-surface !important; + } + } + + // ── Table column sort indicator ─────────────────────────────────── + .v-table-sort-indicator { + filter: invert(1) !important; + } + + .v-table-header-cell-asc .v-table-sort-indicator, + .v-table-header-cell-desc .v-table-sort-indicator { + filter: invert(1) !important; + } + + // ── Vaadin marked cell (surveillance view selection) ────────────── + td.v-table-cell-content.v-table-cell-content-marked { + background: rgba(255, 255, 255, 0.15) !important; + opacity: 0.7 !important; + } + + // ── MIB Compiler / SNMP specific ────────────────────────────────── + .v-tree-node-invalid { + color: #ff6b6b !important; + } + + // MIB console: inline tags need brighter variants for dark bg. + // Note: inline style="" attributes can't be overridden without !important and + // overly broad [style*="color:X"] selectors cause regressions — leave those alone. + font[color="green"] { + color: #6ee7b7 !important; // brighter green + } + + font[color="gray"] { + color: #9ca3af !important; // lighter gray + } + + font[color="orange"] { + color: #fbbf24 !important; // brighter amber + } + + font[color="red"] { + color: #f87171 !important; // softer red + } + + // MIB compiler Vaadin context menu (right-click tree) + .v-contextmenu { + background: $dark-surface-raised !important; + border: 1px solid $dark-border !important; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5) !important; + + .v-contextmenu-item { + color: $dark-text !important; + + &:hover { + background: rgba(255, 255, 255, 0.08) !important; + } + } + + .v-contextmenu-item-selected { + background: rgba(110, 168, 254, 0.15) !important; + color: $dark-link !important; + } + } + + // ── JMX Config Generator steps ──────────────────────────────────── + .v-panel-well { + background: $dark-surface !important; + border-color: $dark-border !important; + color: $dark-text !important; + } + + // ── Catch-all for any remaining white backgrounds on .v-panel ───── + [class*="v-panel"] { + border-color: $dark-border !important; + } + + // ── Wallboard preview / config ──────────────────────────────────── + .wallboard-board { + background: $dark-surface !important; + color: $dark-link !important; + } + + .wallboard-board .v-label { + color: $dark-text !important; + } +} diff --git a/core/web-assets/src/main/assets/style/modern-ui.scss b/core/web-assets/src/main/assets/style/modern-ui.scss new file mode 100644 index 000000000000..606b449eb157 --- /dev/null +++ b/core/web-assets/src/main/assets/style/modern-ui.scss @@ -0,0 +1,1961 @@ +/** + * Licensed to The OpenNMS Group, Inc (TOG) under one or more + * contributor license agreements. See the LICENSE.md file + * distributed with this work for additional information + * regarding copyright ownership. + * + * TOG licenses this file to You under the GNU Affero General + * Public License Version 3 (the "License") or (at your option) + * any later version. You may not use this file except in + * compliance with the License. You may obtain a copy of the + * License at: + * + * https://www.gnu.org/licenses/agpl-3.0.txt + * + * 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. + */ + +// ============================================================================ +// OpenNMS Modern UI Layer +// ============================================================================ +// A CSS modernization layer that transforms the Bootstrap 4 interface into a +// refined, contemporary monitoring platform without touching the underlying +// framework or JSP markup. Designed for NOC operators who live in this UI +// for hours — every pixel serves clarity, every transition reduces fatigue. +// ============================================================================ + +@import "properties.scss"; + +// --------------------------------------------------------------------------- +// Design Tokens +// --------------------------------------------------------------------------- +// Centralized values so the entire UI can be tuned from one place. + +// Radius scale — away from boxy Bootstrap defaults toward softer shapes +$radius-sm: 4px; +$radius-md: 8px; +$radius-lg: 12px; +$radius-pill: 100px; + +// Elevation system — layered depth instead of flat or overblown shadows +$shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.04); +$shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.04); +$shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.07), 0 2px 4px -1px rgba(0, 0, 0, 0.04); +$shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.08), 0 4px 6px -2px rgba(0, 0, 0, 0.04); +$shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.08), 0 10px 10px -5px rgba(0, 0, 0, 0.03); + +// Timing — snappy but not jarring +$transition-fast: 120ms cubic-bezier(0.4, 0, 0.2, 1); +$transition-normal: 200ms cubic-bezier(0.4, 0, 0.2, 1); +$transition-smooth: 300ms cubic-bezier(0.4, 0, 0.2, 1); + +// Surface colors — subtle warmth to avoid the clinical blue-white of old enterprise UIs +$surface-bg: #f8f9fb; +$surface-card: #ffffff; +$surface-header: #f1f3f7; +$surface-hover: #f0f2f6; +$border-subtle: #e2e5ec; +$border-light: #edf0f5; + +// Navigation chrome — the Feather dock + app bar and the legacy Bootstrap +// navbar share --feather-surface-dark (#131736, near-black navy) as their +// background. In light mode we lift this to a readable royal blue. +$nav-blue-light: #1e3d8f; + +// Accent — a slightly warmer, more confident blue than Bootstrap's default +$accent: #2563eb; +$accent-hover: #1d4ed8; +$accent-soft: rgba(37, 99, 235, 0.08); +$accent-ring: rgba(37, 99, 235, 0.20); + +// Text hierarchy +$text-primary: #1a1d26; +$text-secondary: #5a6178; +$text-tertiary: #8b92a8; + +// Severity — refined palette, less saturated for comfortable long-term viewing +$sev-critical: #dc2626; +$sev-major: #ea580c; +$sev-minor: #d97706; +$sev-warning: #ca8a04; +$sev-normal: #16a34a; +$sev-indeterminate: #2563eb; +$sev-cleared: #9ca3af; + +// Pre-computed darker variants (avoids Sass darken() deprecation) +$border-subtle-dark: #cdd1db; +$accent-dark: #1e50c0; +$accent-darker: #1a44a8; +$sev-critical-dark: #b91c1c; +$sev-normal-dark: #138a3e; +$sev-warning-dark: #a87103; +$accent-alert-text: #1e4fc4; +$sev-normal-alert-text: #128a3c; +$sev-warning-alert-text: #8a6003; +$sev-critical-alert-text:#b01f1f; + + +// ============================================================================ +// 1. GLOBAL FOUNDATIONS +// ============================================================================ + +html { + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + text-rendering: optimizeLegibility; +} + +body { + background-color: $surface-bg; + color: $text-primary; + letter-spacing: -0.006em; + line-height: 1.6; +} + +// Smooth scroll for same-page navigation +html { + scroll-behavior: smooth; +} + +// Selection color +::selection { + background: $accent-soft; + color: $text-primary; +} + +// Global transition on interactive elements +a, .btn, .nav-link, .dropdown-item, .list-group-item, +.form-control, .custom-select, .card, .badge { + transition: all $transition-normal; +} + + +// ============================================================================ +// 2. TYPOGRAPHY +// ============================================================================ + +h1, .h1 { font-weight: 700; letter-spacing: -0.025em; color: $text-primary; } +h2, .h2 { font-weight: 600; letter-spacing: -0.02em; color: $text-primary; } +h3, .h3 { font-weight: 600; letter-spacing: -0.015em; color: $text-primary; } +h4, .h4 { font-weight: 600; letter-spacing: -0.01em; color: $text-primary; } +h5, .h5 { font-weight: 600; color: $text-primary; } +h6, .h6 { font-weight: 600; color: $text-secondary; text-transform: uppercase; font-size: 0.75rem; letter-spacing: 0.05em; } + +a { + color: $accent; + text-decoration: none; + + &:hover { + color: $accent-hover; + text-decoration: none; + } +} + +.text-muted { + color: $text-tertiary !important; +} + +small, .small { + color: $text-secondary; +} + + +// ============================================================================ +// 3. CARDS — the primary content container in OpenNMS +// ============================================================================ + +.card { + border: 1px solid $border-subtle; + border-radius: $radius-md; + box-shadow: $shadow-sm; + overflow: hidden; + background: $surface-card; + + &:hover { + box-shadow: $shadow-md; + } +} + +.card-header { + background: $surface-header; + border-bottom: 1px solid $border-subtle; + font-weight: 600; + font-size: 0.9rem; + letter-spacing: -0.01em; + padding: 0.75rem 1rem; + color: $text-primary; + + // When card headers contain links, keep them subtle + a { + color: $text-primary; + &:hover { color: $accent; } + } +} + +.card-body { + padding: 1rem; +} + +.card-footer { + background: $surface-header; + border-top: 1px solid $border-subtle; + padding: 0.625rem 1rem; + font-size: 0.85rem; + color: $text-secondary; +} + + +// ============================================================================ +// 4. TABLES — dense data, needs to breathe without wasting space +// ============================================================================ + +.table { + color: $text-primary; + + thead th { + background: $surface-header; + border-bottom: 2px solid $border-subtle; + border-top: none; + font-weight: 600; + font-size: 0.8rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: $text-secondary; + padding: 0.625rem 0.75rem; + white-space: nowrap; + } + + td { + // Use uniform padding — timeline-resize.js calls getComputedStyle(td).padding + // which returns '' for asymmetric values, causing NaN width and broken image loading. + padding: 0.5rem; + border-top: 1px solid $border-light; + vertical-align: middle; + } + + // Tighter variant used widely in OpenNMS + &.table-sm { + td, th { + padding: 0.375rem; + } + } +} + +.table-striped tbody tr:nth-of-type(odd) { + background-color: rgba(0, 0, 0, 0.015); +} + +.table-hover tbody tr:hover { + background-color: $accent-soft; +} + +// Tables inside cards shouldn't double-border +.card .table { + margin-bottom: 0; + + thead th:first-child { border-top-left-radius: 0; } + thead th:last-child { border-top-right-radius: 0; } +} + + +// ============================================================================ +// 5. BUTTONS +// ============================================================================ + +.btn { + border-radius: $radius-sm; + font-weight: 500; + font-size: 0.875rem; + padding: 0.4375rem 1rem; + letter-spacing: -0.005em; + border: 1px solid transparent; + box-shadow: $shadow-xs; + cursor: pointer; + + &:focus, &.focus { + box-shadow: 0 0 0 3px $accent-ring; + outline: none; + } + + &:active { + transform: translateY(0.5px); + } +} + +.btn-primary { + background: $accent; + border-color: $accent; + + &:hover, &:focus { + background: $accent-hover; + border-color: $accent-hover; + } +} + +.btn-secondary { + background: $surface-card; + border-color: $border-subtle; + color: $text-primary; + box-shadow: $shadow-xs; + + &:hover { + background: $surface-hover; + border-color: $border-subtle-dark; + color: $text-primary; + } + + &:focus { + background: $surface-hover; + border-color: $accent; + color: $text-primary; + } +} + +.btn-sm { + padding: 0.25rem 0.625rem; + font-size: 0.8125rem; + border-radius: $radius-sm; +} + +.btn-lg { + padding: 0.625rem 1.5rem; + font-size: 1rem; + border-radius: $radius-md; +} + +// Outline buttons get a softer treatment +.btn-outline-primary { + color: $accent; + border-color: $accent; + &:hover { + background: $accent-soft; + color: $accent-hover; + border-color: $accent-hover; + } +} + +.btn-outline-secondary { + color: $text-secondary; + border-color: $border-subtle; + &:hover { + background: $surface-hover; + color: $text-primary; + border-color: $border-subtle-dark; + } +} + +// Danger buttons: contained alarm +.btn-danger { + background: $sev-critical; + border-color: $sev-critical; + &:hover { + background: $sev-critical-dark; + border-color: $sev-critical-dark; + } +} + +.btn-success { + background: $sev-normal; + border-color: $sev-normal; + &:hover { + background: $sev-normal-dark; + border-color: $sev-normal-dark; + } +} + +.btn-warning { + background: $sev-warning; + border-color: $sev-warning; + color: #fff; + &:hover { + background: $sev-warning-dark; + border-color: $sev-warning-dark; + color: #fff; + } +} + + +// ============================================================================ +// 6. FORMS — clean, modern input styling +// ============================================================================ + +.form-control { + border: 1px solid $border-subtle; + border-radius: $radius-sm; + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + color: $text-primary; + background: $surface-card; + box-shadow: $shadow-xs; + + &:focus { + border-color: $accent; + box-shadow: 0 0 0 3px $accent-ring; + outline: none; + } + + &::placeholder { + color: $text-tertiary; + } +} + +.custom-select { + border: 1px solid $border-subtle; + border-radius: $radius-sm; + padding: 0.5rem 2rem 0.5rem 0.75rem; + font-size: 0.875rem; + color: $text-primary; + background-color: $surface-card; + box-shadow: $shadow-xs; + appearance: none; + + &:focus { + border-color: $accent; + box-shadow: 0 0 0 3px $accent-ring; + outline: none; + } +} + +.input-group-text { + background: $surface-header; + border: 1px solid $border-subtle; + border-radius: $radius-sm; + color: $text-secondary; + font-size: 0.875rem; +} + +label, .col-form-label { + font-weight: 500; + font-size: 0.85rem; + color: $text-secondary; + margin-bottom: 0.25rem; +} + +.form-group { + margin-bottom: 1rem; +} + +.form-text { + font-size: 0.8rem; + color: $text-tertiary; +} + + +// ============================================================================ +// 7. DROPDOWNS +// ============================================================================ + +.dropdown-menu { + border: 1px solid $border-subtle; + border-radius: $radius-md; + box-shadow: $shadow-lg; + padding: 0.375rem; + margin-top: 0.25rem; + animation: dropdownFadeIn 150ms ease-out; +} + +@keyframes dropdownFadeIn { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.dropdown-item { + border-radius: $radius-sm; + padding: 0.5rem 0.75rem; + font-size: 0.875rem; + color: $text-primary; + + &:hover, &:focus { + background: $accent-soft; + color: $accent-hover; + } + + &.active, &:active { + background: $accent; + color: #fff; + } +} + +.dropdown-divider { + border-top-color: $border-light; + margin: 0.25rem 0.5rem; +} + +.dropdown-header { + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: $text-tertiary; + padding: 0.5rem 0.75rem 0.25rem; +} + + +// ============================================================================ +// 8. BREADCRUMBS +// ============================================================================ + +.breadcrumb { + background: $surface-card; + padding: 0.625rem 1rem; + margin-top: 0.75rem; + margin-bottom: 1rem; + font-size: 0.85rem; + border-radius: $radius-md; + border: 1px solid $border-light; + box-shadow: $shadow-xs; + + .breadcrumb-item { + color: $text-tertiary; + + a { + color: $nav-blue-light; + &:hover { color: lighten($nav-blue-light, 15%); } + } + + &.active { + color: $text-primary; + font-weight: 500; + } + + + .breadcrumb-item::before { + color: $text-tertiary; + content: "\203A"; // thin right-pointing angle + font-size: 1.1em; + } + } +} + + +// ============================================================================ +// 9. NAVIGATION — tabs, pills +// ============================================================================ + +.nav-tabs { + border-bottom: 2px solid $border-light; + + .nav-link { + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + padding: 0.625rem 1rem; + font-weight: 500; + font-size: 0.875rem; + color: $text-secondary; + border-radius: 0; + transition: all $transition-fast; + + &:hover { + color: $text-primary; + border-bottom-color: $border-subtle; + background: transparent; + } + + &.active { + color: $accent; + border-bottom-color: $accent; + background: transparent; + font-weight: 600; + } + } +} + +.nav-pills .nav-link { + border-radius: $radius-sm; + padding: 0.5rem 1rem; + font-size: 0.875rem; + font-weight: 500; + color: $text-secondary; + + &.active { + background: $accent; + color: #fff; + } + + &:hover:not(.active) { + background: $accent-soft; + color: $accent; + } +} + + +// ============================================================================ +// 10. BADGES — compact, modern +// ============================================================================ + +.badge { + font-weight: 600; + font-size: 0.7rem; + letter-spacing: 0.01em; + padding: 0.25em 0.6em; + border-radius: $radius-sm; +} + +.badge-pill { + border-radius: $radius-pill; + padding: 0.3em 0.7em; +} + +// Severity badges: left-border accent for quick visual parsing +.badge-severity-critical, .badge-Critical { border-left: 3px solid $sev-critical; } +.badge-severity-major, .badge-Major { border-left: 3px solid $sev-major; } +.badge-severity-minor, .badge-Minor { border-left: 3px solid $sev-minor; } +.badge-severity-warning, .badge-Warning { border-left: 3px solid $sev-warning; } +.badge-severity-normal, .badge-Normal { border-left: 3px solid $sev-normal; } + + +// ============================================================================ +// 11. ALERTS +// ============================================================================ + +.alert { + border-radius: $radius-md; + border: none; + border-left: 4px solid transparent; + font-size: 0.875rem; + padding: 0.875rem 1rem; + box-shadow: $shadow-xs; +} + +.alert-info { + background: rgba(37, 99, 235, 0.06); + border-left-color: $accent; + color: $accent-dark; +} + +.alert-success { + background: rgba(22, 163, 74, 0.06); + border-left-color: $sev-normal; + color: $sev-normal-alert-text; +} + +.alert-warning { + background: rgba(202, 138, 4, 0.08); + border-left-color: $sev-warning; + color: $sev-warning-alert-text; +} + +.alert-danger { + background: rgba(220, 38, 38, 0.06); + border-left-color: $sev-critical; + color: $sev-critical-alert-text; +} + + +// ============================================================================ +// 12. LIST GROUPS +// ============================================================================ + +.list-group-item { + border-color: $border-light; + padding: 0.625rem 1rem; + font-size: 0.875rem; + transition: background $transition-fast; + + &.active { + background: $accent; + border-color: $accent; + color: #fff; + } +} + +.list-group-item-action:hover { + background: $surface-hover; +} + + +// ============================================================================ +// 13. MODALS +// ============================================================================ + +.modal-content { + border: none; + border-radius: $radius-lg; + box-shadow: $shadow-xl; + overflow: hidden; +} + +.modal-header { + border-bottom: 1px solid $border-light; + padding: 1rem 1.25rem; + + .modal-title { + font-weight: 600; + font-size: 1.1rem; + } +} + +.modal-body { + padding: 1.25rem; +} + +.modal-footer { + border-top: 1px solid $border-light; + padding: 0.875rem 1.25rem; +} + +// Backdrop +.modal-backdrop.show { + opacity: 0.4; +} + +// Modal entrance animation +.modal.fade .modal-dialog { + transition: transform $transition-smooth, opacity $transition-smooth; + transform: translateY(-10px) scale(0.98); +} + +.modal.show .modal-dialog { + transform: translateY(0) scale(1); +} + + +// ============================================================================ +// 14. PAGINATION +// ============================================================================ + +.pagination { + .page-link { + border: 1px solid $border-subtle; + color: $text-secondary; + padding: 0.375rem 0.75rem; + font-size: 0.85rem; + font-weight: 500; + margin: 0 2px; + border-radius: $radius-sm; + transition: all $transition-fast; + + &:hover { + background: $accent-soft; + border-color: $accent; + color: $accent; + } + } + + .page-item.active .page-link { + background: $accent; + border-color: $accent; + color: #fff; + box-shadow: $shadow-sm; + } + + .page-item:first-child .page-link, + .page-item:last-child .page-link { + border-radius: $radius-sm; + } +} + + +// ============================================================================ +// 15. PROGRESS BARS +// ============================================================================ + +.progress { + border-radius: $radius-pill; + height: 0.5rem; + background: $border-light; + overflow: hidden; +} + +.progress-bar { + border-radius: $radius-pill; + transition: width $transition-smooth; +} + + +// ============================================================================ +// 16. TOOLTIPS & POPOVERS +// ============================================================================ + +.tooltip-inner { + border-radius: $radius-sm; + padding: 0.375rem 0.625rem; + font-size: 0.8rem; + font-weight: 500; + box-shadow: $shadow-md; +} + +.popover { + border: 1px solid $border-subtle; + border-radius: $radius-md; + box-shadow: $shadow-lg; + + .popover-header { + background: $surface-header; + border-bottom: 1px solid $border-light; + font-weight: 600; + font-size: 0.875rem; + border-radius: $radius-md $radius-md 0 0; + } + + .popover-body { + padding: 0.75rem 1rem; + font-size: 0.875rem; + } +} + + +// ============================================================================ +// 17. OPENNMS-SPECIFIC: CONTENT BOXES (.box) +// ============================================================================ + +.box { + border-radius: $radius-md; + border: 1px solid $border-subtle; + box-shadow: $shadow-sm; + overflow: hidden; + + h2.module-title { + background-color: $surface-header; + background-image: none; // kill the old gradient + border-bottom: 1px solid $border-subtle; + font-size: 0.95rem; + font-weight: 600; + letter-spacing: -0.01em; + padding: 0.75rem 1rem; + line-height: 1.4; + } + + .bottom { + padding: 1rem; + } +} + + +// ============================================================================ +// 18. OPENNMS-SPECIFIC: SEVERITY ROWS +// ============================================================================ +// Severity table rows get a refined left-border accent instead of the full +// background wash — easier on the eyes during hours of monitoring. + +.severity { + .severity-critical, .severity-Critical { + border-left: 3px solid $sev-critical; + background-color: rgba($sev-critical, 0.12); + } + .severity-major, .severity-Major { + border-left: 3px solid $sev-major; + background-color: rgba($sev-major, 0.12); + } + .severity-minor, .severity-Minor { + border-left: 3px solid $sev-minor; + background-color: rgba($sev-minor, 0.12); + } + .severity-warning, .severity-Warning { + border-left: 3px solid $sev-warning; + background-color: rgba($sev-warning, 0.12); + } + .severity-indeterminate, .severity-Indeterminate { + border-left: 3px solid $sev-indeterminate; + background-color: rgba($sev-indeterminate, 0.12); + } + .severity-normal, .severity-Normal { + border-left: 3px solid $sev-normal; + background-color: rgba($sev-normal, 0.12); + } + .severity-cleared, .severity-Cleared { + border-left: 3px solid $sev-cleared; + background-color: rgba($sev-cleared, 0.12); + } +} + + +// ============================================================================ +// 19. OPENNMS-SPECIFIC: NODE HEADER +// ============================================================================ + +.nodeHeader { + background: $surface-card; + border-bottom: 1px solid $border-subtle; + padding: 0.75rem 1rem; + margin-bottom: 1rem; + border-radius: $radius-md; + box-shadow: $shadow-xs; +} + + +// ============================================================================ +// 20. FOOTER +// ============================================================================ + +#footer.card-footer { + background: $surface-card; + border: 1px solid $border-light; + border-radius: $radius-md; + box-shadow: $shadow-xs; + font-size: 0.8rem; + color: $text-tertiary; + padding: 0.625rem 1rem; + margin-top: 0.75rem; + margin-bottom: 1rem; + // Don't stretch edge-to-edge — match the inset feel of breadcrumbs + margin-left: 0; + margin-right: 0; +} + + +// ============================================================================ +// 21. SCROLLBAR REFINEMENT (Webkit) +// ============================================================================ + +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: rgba(0, 0, 0, 0.12); + border-radius: $radius-pill; + + &:hover { + background: rgba(0, 0, 0, 0.2); + } +} + +// Firefox +html { + scrollbar-color: rgba(0, 0, 0, 0.12) transparent; + scrollbar-width: thin; +} + +// Dark mode — lighten scrollbar thumb so it's visible on dark surfaces +body.open-dark { + ::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.04); + } + + ::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.18); + + &:hover { + background: rgba(255, 255, 255, 0.28); + } + } + + scrollbar-color: rgba(255, 255, 255, 0.18) transparent; +} + + +// ============================================================================ +// 22. CLOSE BUTTON +// ============================================================================ + +.close { + opacity: 0.4; + font-weight: 300; + font-size: 1.5rem; + text-shadow: none; + transition: opacity $transition-fast; + + &:hover { + opacity: 0.8; + } +} + + +// ============================================================================ +// 23. HR +// ============================================================================ + +hr { + border-top: 1px solid $border-light; +} + + +// ============================================================================ +// 24. CODE +// ============================================================================ + +code { + background: rgba($accent, 0.06); + border-radius: $radius-sm; + padding: 0.15em 0.4em; + font-size: 0.85em; + color: $accent-darker; +} + +pre { + background: #f1f5f9; + color: #1e293b; + border-radius: $radius-md; + padding: 1rem; + border: none; + font-size: 0.85rem; + box-shadow: $shadow-sm; + + code { + background: transparent; + color: inherit; + padding: 0; + border-radius: 0; + } +} + +body.open-dark pre { + background: #1e293b; + color: #e2e8f0; +} + +kbd { + background: $text-primary; + border-radius: $radius-sm; + padding: 0.15em 0.45em; + font-size: 0.85em; + box-shadow: inset 0 -1px 0 rgba(0,0,0,0.25); +} + + +// ============================================================================ +// 25. RESOURCE GRAPH SIDEBAR +// ============================================================================ + +.resource-graphs-sidebar .nav > li > a { + border-left-width: 3px; + border-radius: 0; + transition: all $transition-fast; + padding: 0.375rem 0.75rem; + + &:hover { + background: $accent-soft; + } + + &.active { + border-left-color: $accent; + color: $accent; + font-weight: 600; + background: $accent-soft; + } +} + + +// ============================================================================ +// 26. GRAPH / BACKSHIFT MODERNIZATION +// ============================================================================ + +// Per-resource graph cards — give them consistent card chrome +// NOTE: no overflow:hidden here — Flot renders canvases as position:absolute inside +// the graph-container, and tooltips/selection overlays need to escape the card boundary. +#graph-results .card { + border-radius: $radius-md; + border: 1px solid $border-light; + box-shadow: $shadow-sm; + margin-bottom: 1.25rem; +} + +// Controls strip above each graph +.graph-aux-controls { + padding: 0.5rem 0.75rem; + background: $surface-card; + border-bottom: 1px solid $border-light; + font-size: 0.82rem; +} + +// Flot legend modernisation +.legend > div { + background: $surface-card !important; + border: 1px solid $border-light !important; + border-radius: $radius-sm !important; + box-shadow: $shadow-xs !important; + padding: 0.25rem 0.5rem !important; +} +.legend table { + font-size: 0.8rem; +} +.legendLabel { + color: $text-secondary; + padding-left: 0.25rem; +} + +// flot-datatable panel (Data tab) +.flot-datatable-tabs { + background: $surface-card; + border-bottom: 1px solid $border-light; + padding: 0.25rem 0.5rem 0; + font-size: 0.8rem; +} +.flot-datatable-tab { + display: inline-block; + padding: 0.2rem 0.75rem; + border-radius: $radius-sm $radius-sm 0 0; + border: 1px solid transparent; + cursor: pointer; + color: $text-secondary; + + &:hover { + color: $text-primary; + background: $surface-hover; + } +} + +.flot-datatable-data { + font-size: 0.8rem; + // inline style background:white is overridden by dark-mode.scss; light mode is fine. + + table { + width: 100%; + border-collapse: collapse; + + th { + background: $surface-header; + color: $text-secondary; + font-weight: 600; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 0.35rem 0.5rem; + border-bottom: 1px solid $border-subtle; + } + + td { + padding: 0.3rem 0.5rem; + border-bottom: 1px solid $border-light; + font-variant-numeric: tabular-nums; + } + + tr:hover td { + background: $surface-hover; + } + } +} + +// Tick labels — slightly muted vs raw black +.flot-tick-label { + color: $text-tertiary; + font-size: 0.75rem; +} + +// Time-range form strip +#graph-results .form-group.form-row { + background: $surface-header; + border: 1px solid $border-light; + border-radius: $radius-md; + padding: 0.5rem 0.75rem; + margin-bottom: 1rem; +} + + +// ============================================================================ +// 26b. DASHBOARD SEVERITY INDICATORS +// ============================================================================ + +.CLEARED { background: $sev-cleared; background-image: none; border: none; border-radius: $radius-sm; } +.NORMAL { background: $sev-normal; background-image: none; border: none; border-radius: $radius-sm; color: #fff; } +.INDETERMINATE { background: $sev-indeterminate; background-image: none; border: none; border-radius: $radius-sm; color: #fff; } +.WARNING { background: $sev-warning; background-image: none; border: none; border-radius: $radius-sm; } +.MINOR { background: $sev-minor; background-image: none; border: none; border-radius: $radius-sm; } +.MAJOR { background: $sev-major; background-image: none; border: none; border-radius: $radius-sm; color: #fff; } +.CRITICAL { background: $sev-critical; background-image: none; border: none; border-radius: $radius-sm; color: #fff; } + + +// ============================================================================ +// 27. ANIMATION UTILITIES +// ============================================================================ + +// Fade-in for page content (applied via JS or page load) +@keyframes fadeInUp { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +// Content top spacing — prevent breadcrumb from jamming into header +#content { + padding-top: 0.5rem; +} + +// Content area entrance animation +#content > .breadcrumb, +#content > .card, +#content > .row, +#content > h2, +#content > h3, +#content > form, +#content > .table-responsive, +#content > table { + animation: fadeInUp 350ms ease-out both; +} + +// Stagger children +#content > :nth-child(1) { animation-delay: 0ms; } +#content > :nth-child(2) { animation-delay: 40ms; } +#content > :nth-child(3) { animation-delay: 80ms; } +#content > :nth-child(4) { animation-delay: 120ms; } +#content > :nth-child(5) { animation-delay: 160ms; } + + +// ============================================================================ +// 28. JUMBOTRON MODERNIZATION +// ============================================================================ + +.jumbotron { + background: $surface-header; + border-radius: $radius-lg; + border: 1px solid $border-subtle; + box-shadow: $shadow-sm; + padding: 2rem; +} + + +// ============================================================================ +// 29. INPUT GROUP REFINEMENT +// ============================================================================ + +.input-group { + .input-group-text { + border-color: $border-subtle; + } + + .form-control { + &:focus { + z-index: 3; + } + } + + // Connected radius + > .form-control:not(:first-child), + > .custom-select:not(:first-child) { + border-top-left-radius: 0; + border-bottom-left-radius: 0; + } + + > .form-control:not(:last-child), + > .custom-select:not(:last-child) { + border-top-right-radius: 0; + border-bottom-right-radius: 0; + } +} + + +// ============================================================================ +// 30. VAADIN 8 MODERNIZATION +// ============================================================================ +// The Vaadin 8 Reindeer legacy theme looks dated. These overrides bring +// Vaadin widgets (tables, buttons, panels, forms) in line with the modern +// Bootstrap layer above, without touching the Vaadin theme files themselves. + +// System font stack — modern, no external font loading required +$font-system: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !default; + +// ── Core Vaadin app background ─────────────────────────────────────── +.v-ui { + background: $surface-bg !important; + color: $text-primary !important; + font-family: $font-system !important; + -webkit-font-smoothing: antialiased !important; + -moz-osx-font-smoothing: grayscale !important; +} + +.v-app { + background: $surface-bg !important; + color: $text-primary !important; + font-family: $font-system !important; +} + +// ── Vaadin Labels ──────────────────────────────────────────────────── +.v-label { + color: $text-primary !important; +} + +.v-caption { + color: $text-secondary !important; + font-weight: 500 !important; + font-size: 0.85rem !important; +} + +// ── Vaadin Panels ──────────────────────────────────────────────────── +.v-panel { + background: $surface-card !important; + border: 1px solid $border-subtle !important; + border-radius: $radius-md !important; + box-shadow: $shadow-sm !important; +} + +.v-panel-content { + background: $surface-card !important; + color: $text-primary !important; +} + +.v-panel-caption, +.v-panel-caption-light, +.v-panel-caption-novscroll { + background: $surface-header !important; + background-image: none !important; + color: $text-primary !important; + border-bottom: 1px solid $border-subtle !important; + font-weight: 600 !important; + font-size: 0.9rem !important; + padding: 0.625rem 1rem !important; + border-radius: $radius-md $radius-md 0 0 !important; +} + +.v-panel-deco { + background: transparent !important; + display: none !important; +} + +// ── Vaadin Tables ──────────────────────────────────────────────────── +.v-table { + background: $surface-card !important; + border: 1px solid $border-subtle !important; + border-radius: $radius-md !important; + overflow: hidden !important; +} + +.v-table-header-wrap { + background: $surface-header !important; + border-bottom: 2px solid $border-subtle !important; +} + +.v-table-header-cell { + background: $surface-header !important; + background-image: none !important; + border-right: 1px solid $border-light !important; + padding: 0.5rem 0.625rem !important; +} + +.v-table-caption-container { + font-weight: 600 !important; + font-size: 0.8rem !important; + text-transform: uppercase !important; + letter-spacing: 0.04em !important; + color: $text-secondary !important; +} + +.v-table-body { + border: none !important; +} + +.v-table-row, +.v-table-row-odd { + background: $surface-card !important; + transition: background $transition-fast !important; +} + +.v-table-row-odd { + background: rgba(0, 0, 0, 0.015) !important; +} + +.v-table-row:hover, +.v-table-row-odd:hover { + background: $accent-soft !important; +} + +.v-table-cell-content { + border-bottom: 1px solid $border-light !important; + padding: 0.375rem 0.625rem !important; + color: $text-primary !important; +} + +.v-table-cell-wrapper { + line-height: 1.5 !important; + color: $text-primary !important; +} + +.v-table-resizer { + border-color: $border-light !important; +} + +.v-table-column-selector { + background: $surface-header !important; + border-color: $border-subtle !important; +} + +// ── Vaadin Buttons ─────────────────────────────────────────────────── +.v-button { + background: $surface-card !important; + background-image: none !important; + border: 1px solid $border-subtle !important; + border-radius: $radius-sm !important; + color: $text-primary !important; + font-weight: 500 !important; + font-size: 0.875rem !important; + padding: 0.375rem 0.875rem !important; + box-shadow: $shadow-xs !important; + text-shadow: none !important; + cursor: pointer !important; + transition: all $transition-normal !important; + + &:hover, + &:focus { + background: $surface-hover !important; + border-color: $border-subtle-dark !important; + box-shadow: $shadow-sm !important; + } + + &:active { + transform: translateY(0.5px) !important; + } +} + +.v-button-wrap { + background: transparent !important; + background-image: none !important; + padding: 0 !important; +} + +.v-button-caption { + color: inherit !important; +} + +// ── Vaadin Links ───────────────────────────────────────────────────── +.v-link a span { + color: $accent !important; +} + +.v-link a:hover span { + color: $accent-hover !important; +} + +// ── Vaadin Forms ───────────────────────────────────────────────────── +.v-textfield, +.v-textarea, +.v-richtextarea { + background: $surface-card !important; + border: 1px solid $border-subtle !important; + border-radius: $radius-sm !important; + color: $text-primary !important; + font-size: 0.875rem !important; + padding: 0.4375rem 0.75rem !important; + box-shadow: $shadow-xs !important; + transition: border-color $transition-fast, box-shadow $transition-fast !important; + + &:focus { + border-color: $accent !important; + box-shadow: 0 0 0 3px $accent-ring !important; + outline: none !important; + } +} + +.v-select-select, +.v-filterselect-input { + background: $surface-card !important; + border: 1px solid $border-subtle !important; + border-radius: $radius-sm !important; + color: $text-primary !important; + font-size: 0.875rem !important; + padding: 0.375rem 0.625rem !important; +} + +.v-filterselect-button { + background: $surface-header !important; + border-color: $border-subtle !important; + border-radius: 0 $radius-sm $radius-sm 0 !important; +} + +.v-filterselect-suggestpopup { + background: $surface-card !important; + border: 1px solid $border-subtle !important; + border-radius: $radius-md !important; + box-shadow: $shadow-lg !important; + overflow: hidden !important; +} + +// ── Vaadin MenuBar ─────────────────────────────────────────────────── +.v-menubar { + background: $surface-card !important; + background-image: none !important; + border: 1px solid $border-subtle !important; + border-radius: $radius-sm !important; + box-shadow: $shadow-xs !important; +} + +.v-menubar-menuitem { + color: $text-primary !important; + padding: 0.375rem 0.75rem !important; + transition: background $transition-fast !important; + + &:hover { + background: $accent-soft !important; + color: $accent-hover !important; + } +} + +.v-menubar-submenu { + background: $surface-card !important; + border: 1px solid $border-subtle !important; + border-radius: $radius-md !important; + box-shadow: $shadow-lg !important; + padding: 0.25rem !important; +} + +.v-menubar-submenu .v-menubar-menuitem { + border-radius: $radius-sm !important; + padding: 0.375rem 0.75rem !important; + + &:hover { + background: $accent-soft !important; + } +} + +// ── Vaadin TabSheet ────────────────────────────────────────────────── +.v-tabsheet-tabcontainer { + background: transparent !important; + border-bottom: 2px solid $border-light !important; +} + +.v-tabsheet-tabitem .v-caption { + background: transparent !important; + background-image: none !important; + border: none !important; + border-bottom: 2px solid transparent !important; + margin-bottom: -2px !important; + color: $text-secondary !important; + font-weight: 500 !important; + padding: 0.5rem 1rem !important; + transition: all $transition-fast !important; + + &:hover { + color: $text-primary !important; + border-bottom-color: $border-subtle !important; + } +} + +.v-tabsheet-tabitem-selected .v-caption { + color: $accent !important; + border-bottom-color: $accent !important; + font-weight: 600 !important; +} + +.v-tabsheet-content { + background: $surface-card !important; + border: 1px solid $border-subtle !important; + border-top: none !important; + border-radius: 0 0 $radius-md $radius-md !important; +} + +.v-tabsheet-deco { + display: none !important; +} + +// ── Vaadin Windows / Dialogs ───────────────────────────────────────── +.v-window { + background: $surface-card !important; + border: none !important; + border-radius: $radius-lg !important; + box-shadow: $shadow-xl !important; + overflow: hidden !important; +} + +.v-window-outerheader, +.v-window-header { + background: $surface-header !important; + color: $text-primary !important; + font-weight: 600 !important; + padding: 0.75rem 1rem !important; + border-bottom: 1px solid $border-subtle !important; +} + +.v-window-contents { + background: $surface-card !important; + color: $text-primary !important; +} + +.v-window-footer { + background: $surface-header !important; + border-top: 1px solid $border-subtle !important; + padding: 0.625rem 1rem !important; +} + +// ── Vaadin Accordion ───────────────────────────────────────────────── +.v-accordion-item-caption { + background: $surface-header !important; + background-image: none !important; + border-color: $border-subtle !important; + color: $text-primary !important; + font-weight: 500 !important; + padding: 0.625rem 1rem !important; + transition: background $transition-fast !important; + + &:hover { + background: $surface-hover !important; + } +} + +.v-accordion-item-content { + background: $surface-card !important; + border-color: $border-subtle !important; +} + +// ── Vaadin Notifications ───────────────────────────────────────────── +.v-Notification { + background: $surface-card !important; + border: 1px solid $border-subtle !important; + border-radius: $radius-md !important; + box-shadow: $shadow-lg !important; + color: $text-primary !important; + font-size: 0.875rem !important; +} + +// ── Vaadin Tree ────────────────────────────────────────────────────── +.v-tree-node-caption { + color: $text-primary !important; + padding: 0.25rem 0.5rem !important; + border-radius: $radius-sm !important; + transition: background $transition-fast !important; + + &:hover { + background: $accent-soft !important; + } +} + +.v-tree-node-selected { + background: $accent-soft !important; + color: $accent !important; +} + +// ── Vaadin Loading Indicator ───────────────────────────────────────── +.v-loading-indicator { + background-color: $accent !important; + height: 3px !important; +} + +// ── Surveillance View caption ──────────────────────────────────────── +.v-caption-surveillance-view { + background-color: $surface-header !important; + background-image: none !important; + border-color: $border-subtle !important; + border-bottom: 1px solid $border-subtle !important; + color: $text-primary !important; + font-weight: 600 !important; + border-radius: $radius-md $radius-md 0 0 !important; +} + +// ── Dashboard / Wallboard ──────────────────────────────────────────── +.dashboard.v-app { + background: $surface-bg !important; +} + +.dashboard .v-portlet-slot { + background: $surface-card !important; + border-radius: $radius-md !important; + box-shadow: $shadow-sm !important; +} + +.dashboard .v-portlet-header { + background: $surface-header !important; + color: $text-primary !important; + text-shadow: none !important; + border-radius: $radius-md $radius-md 0 0 !important; + font-weight: 600 !important; +} + +.dashboard .header { + background-color: $surface-header !important; + border-color: $border-subtle !important; +} + +.dashboard .v-portlet-content-wrapper { + border: none !important; +} + +// ── Dashboard severity (in Vaadin context) ─────────────────────────── +.severity.Critical { background-image: none !important; } +.severity.Major { background-image: none !important; } +.severity.Minor { background-image: none !important; } +.severity.Warning { background-image: none !important; } +.severity.Indeterminate { background-image: none !important; } +.severity.Normal { background-image: none !important; } +.severity.Cleared { background-image: none !important; } + +// Alert details dashlet +.alert-details-dashlet .onms-header-cell { + background-color: $surface-header !important; + border: 1px solid $border-subtle !important; + color: $text-primary !important; + font-weight: 600 !important; +} + +.alert-details-dashlet .onms-cell { + border: 1px solid $border-light !important; + background-color: $surface-card !important; + color: $text-primary !important; +} + +// ── Vaadin SplitPanel ────────────────────────────────────────────── +.v-splitpanel-horizontal, +.v-splitpanel-vertical { + background: $surface-card !important; +} + +.v-splitpanel-hsplitter, +.v-splitpanel-vsplitter { + background: $border-subtle !important; + background-image: none !important; + + div { + background: transparent !important; + background-image: none !important; + } +} + +.v-splitpanel-hsplitter { + width: 4px !important; + cursor: col-resize !important; +} + +.v-splitpanel-vsplitter { + height: 4px !important; + cursor: row-resize !important; +} + +// ── Vaadin PopupView ─────────────────────────────────────────────── +.v-popupview-popup { + background: $surface-card !important; + border: 1px solid $border-subtle !important; + border-radius: $radius-md !important; + box-shadow: $shadow-lg !important; +} + +// ── Vaadin DateField ─────────────────────────────────────────────── +.v-datefield [class*="textfield"] { + background: $surface-card !important; + border: 1px solid $border-subtle !important; + border-radius: $radius-sm !important; + color: $text-primary !important; +} + +.v-datefield [class*="button"] { + background: $surface-header !important; + background-image: none !important; + border-color: $border-subtle !important; + border-radius: 0 $radius-sm $radius-sm 0 !important; +} + +.v-datefield-calendarpanel { + background: $surface-card !important; + border: 1px solid $border-subtle !important; + border-radius: $radius-md !important; + box-shadow: $shadow-lg !important; + color: $text-primary !important; +} + +.v-datefield-calendarpanel-day { + border-radius: $radius-sm !important; + transition: background $transition-fast !important; + + &:hover { + background: $accent-soft !important; + } +} + +.v-datefield-calendarpanel-day-selected { + background: $accent !important; + color: #fff !important; + border-radius: $radius-sm !important; +} + +// ── Vaadin Checkbox / Radio ──────────────────────────────────────── +.v-checkbox > input, +.v-radiobutton > input { + accent-color: $accent !important; +} + +.v-checkbox > label, +.v-radiobutton > label { + color: $text-primary !important; +} + +// ── Vaadin Tooltip ───────────────────────────────────────────────── +.v-tooltip { + background: $text-primary !important; + color: #fff !important; + border-radius: $radius-sm !important; + box-shadow: $shadow-md !important; + font-size: 0.8rem !important; + padding: 0.375rem 0.625rem !important; +} + +.v-tooltip .v-tooltip-text { + color: #fff !important; +} + +// ── Vaadin ContextMenu ───────────────────────────────────────────── +.v-contextmenu { + background: $surface-card !important; + border: 1px solid $border-subtle !important; + border-radius: $radius-md !important; + box-shadow: $shadow-lg !important; + padding: 0.25rem !important; +} + +.v-contextmenu .v-contextmenu-menuitem { + padding: 0.375rem 0.75rem !important; + border-radius: $radius-sm !important; + color: $text-primary !important; + transition: background $transition-fast !important; + + &:hover { + background: $accent-soft !important; + } +} + +// ── Vaadin Overlay / Backdrop ────────────────────────────────────── +.v-window-modalitycurtain { + background: rgba(0, 0, 0, 0.35) !important; +} + +// ── Generated Body (standalone Vaadin pages) ─────────────────────── +.v-generated-body { + background: $surface-bg !important; + margin: 0 !important; + font-family: $font-system !important; + -webkit-font-smoothing: antialiased !important; + -moz-osx-font-smoothing: grayscale !important; +} + +// ── Layout wrappers ──────────────────────────────────────────────── +.v-verticallayout, +.v-horizontallayout, +.v-gridlayout, +.v-csslayout, +.v-absolutelayout-wrapper { + color: $text-primary !important; +} + +// ── Nuclear Reindeer gradient + text-shadow reset ────────────────── +// The Reindeer/Runo legacy theme applies gradient background-images +// and text-shadows to nearly every widget. Kill them globally so our +// modern flat styles take effect cleanly. +.v-panel-caption, +.v-panel-caption-light, +.v-table-header-cell, +.v-table-column-selector, +.v-button, +.v-button-wrap, +.v-nativebutton, +.v-menubar, +.v-menubar-menuitem, +.v-menubar-submenu, +.v-tabsheet-tabitem .v-caption, +.v-tabsheet-tabcontainer, +.v-accordion-item-caption, +.v-window-outerheader, +.v-window-header, +.v-window-footer, +.v-filterselect-button, +.v-datefield [class*="button"], +.v-splitpanel-hsplitter div, +.v-splitpanel-vsplitter div, +.v-Notification { + background-image: none !important; + text-shadow: none !important; +} + +// ── Topology page ────────────────────────────────────────────────── +.v-app.topo_default { + background: $surface-bg !important; + color: $text-primary !important; +} + +// ── Vaadin ComboBox / FilterSelect popup ─────────────────────────── +.v-filterselect-suggestmenu { + background: $surface-card !important; + border: 1px solid $border-subtle !important; + border-radius: $radius-md !important; + box-shadow: $shadow-lg !important; +} + +.v-filterselect-suggestmenu .gwt-MenuItem { + color: $text-primary !important; + padding: 0.375rem 0.75rem !important; + border-radius: $radius-sm !important; + transition: background $transition-fast !important; + + &:hover, + &.gwt-MenuItem-selected { + background: $accent-soft !important; + color: $accent-hover !important; + } +} + +.v-filterselect-status { + background: $surface-header !important; + border-color: $border-subtle !important; + color: $text-tertiary !important; + font-size: 0.75rem !important; + padding: 0.25rem 0.5rem !important; +} + +// ── Vaadin NativeSelect ──────────────────────────────────────────── +.v-nativeselect select { + background: $surface-card !important; + border: 1px solid $border-subtle !important; + border-radius: $radius-sm !important; + color: $text-primary !important; + padding: 0.375rem 0.625rem !important; +} + +// ── Vaadin Upload ────────────────────────────────────────────────── +.v-upload .v-button { + border-radius: $radius-sm !important; +} + +// ── Vaadin TabSheet scroller ─────────────────────────────────────── +.v-tabsheet-scroller { + background: transparent !important; +} + +// ── Vaadin Error/Required indicators ─────────────────────────────── +.v-errorindicator { + color: $sev-critical !important; +} + +// ── Vaadin Info Panel (Topology sidebar) ─────────────────────────── +.info-panel { + background: $surface-card !important; + border: 1px solid $border-subtle !important; + border-radius: $radius-md !important; + box-shadow: $shadow-sm !important; +} + +// ── Toolbar (Topology) ───────────────────────────────────────────── +.toolbar { + background: $surface-header !important; + border-bottom: 1px solid $border-subtle !important; +} + +.toolbar .v-button { + background: transparent !important; + border-color: transparent !important; + box-shadow: none !important; + + &:hover { + background: $accent-soft !important; + } +} + +// --------------------------------------------------------------------------- +// Light-mode navigation chrome +// --------------------------------------------------------------------------- +// Override the near-black nav surfaces (#131736) with a lighter royal blue. +// body:not(.open-dark) covers both the pre-theme-load state and open-light. +// The CSS variable --feather-surface-dark drives the Feather dock and app bar; +// .opennms-bg-chromatic-black is the hardcoded class on the legacy Bootstrap navbar. + +body:not(.open-dark) { + --feather-surface-dark: #{$nav-blue-light}; + + .opennms-bg-chromatic-black { + background-color: $nav-blue-light !important; + } +} diff --git a/docs/superpowers/TODO.md b/docs/superpowers/TODO.md new file mode 100644 index 000000000000..3c0b2f8012cc --- /dev/null +++ b/docs/superpowers/TODO.md @@ -0,0 +1,177 @@ +# OpenNMS Superpowers — TODO + +## API Token Smoke Tests + +The feature is implemented and upstream-PR-ready, but has no smoke test coverage in the +`smoke-test/` module. These need to be written as a follow-up PR after the feature lands. + +### What to write + +**File:** `smoke-test/src/test/java/org/opennms/smoketest/ApiTokenIT.java` + +This should be a standalone REST-based IT (no Selenium, no UI) that uses +`OpenNMSStack.MINIMAL` — same pattern as `NodeRestIT.java` and `RestSessionIT.java`. +No new containers needed; it exercises the REST API directly via `HttpClient` or JAX-RS +`Client` (see `RestClient` for the helper, or raw Apache HttpClient for bearer header +control since `RestClient` is basic-auth-only). + +**Test cases to cover** (mirrors the manual E2E suite we've validated): + +#### Authentication +- Basic auth still works on `/api/v2/apiTokens` → 200 +- Valid bearer token (`onms_...`) → 200 +- Invalid bearer (correct prefix, wrong hash) → 401 +- Malformed bearer (no `onms_` prefix) → 401 +- No `Authorization` header → 401 + +#### Token lifecycle +- `POST /api/v2/apiTokens` returns 201 with `token`, `id`, `createdAt`, `expiresAt` +- Returned `token` starts with `onms_` and is 69 characters +- `lastUsedAt` is null on a fresh token, non-null after the token is used for auth +- `DELETE /api/v2/apiTokens/{id}` returns 204 +- Using a revoked token returns 401 +- Revoking a non-existent token returns 404 + +#### Information leakage +- `GET /api/v2/apiTokens` response does NOT contain `tokenHash` field +- `POST` with `expiresInDays: 9999` returns 400, body does not contain "365" +- `POST` with `expiresInDays: -1` returns 400 + +#### Admin cross-user operations +- Admin can create token for another user (`POST ?username=rtc`) → 201 +- Admin can list another user's tokens (`GET ?username=rtc`) → 200 +- Admin can revoke another user's token (`DELETE /{id}` as admin) → 204 + +#### Bulk revoke +- `DELETE /api/v2/apiTokens?username=admin` → 204 +- Subsequent `GET /api/v2/apiTokens` returns empty list + +### Implementation notes + +- `RestClient` in `smoke-test/src/main/java/org/opennms/smoketest/utils/RestClient.java` + is JAX-RS-based and only supports basic auth. For bearer token tests you'll need to + construct requests manually — use `javax.ws.rs.client.ClientBuilder` with an + `Authorization: Bearer ` header directly, or Apache `HttpClient` (already on + the classpath via `RestSessionIT`). +- The `rtc` user exists in the default OpenNMS install with no `ROLE_REST`. Admin + cross-user tests should use `admin:admin` credentials (basic) for the admin actor. + Token-holder is `rtc` (or create a separate user via REST if you need a ROLE_REST + non-admin for IDOR coverage). +- Token response JSON is not currently backed by a JAXB type in the smoke-test classpath + — parse with Jackson `ObjectMapper` or use `Response.readEntity(Map.class)` via a + `GenericType>`. +- Test cleanup: revoke all tokens for `admin` and `rtc` in `@After`. + +### Where this lives in the build + +The `smoke-test` module is gated behind the `smoke` Maven profile: + +```bash +./compile.pl -DskipTests -Dsmoke --projects :smoke-test -am install +./compile.pl -t -Dsmoke --projects :smoke-test install -Dtest=ApiTokenIT +``` + +The test class must end in `IT` and extend nothing (or `OpenNMSSeleniumIT` if Selenium +is needed, but it isn't here). Mark with `@Category(FlakyTests.class)` only if +genuinely flaky — REST-only tests shouldn't be. + +### Include in the same PR as the feature + +`ApiTokenIT.java` should be part of the upstream feature PR, not a follow-up. +Reviewers will ask for tests if they're absent, and the smoke test framework +runs against the current build — there's no chicken-and-egg problem. +`OpenNMSStack.MINIMAL` spins up a container from the source being reviewed, +so the tests exercise the feature code directly. + +--- + +## Vaadin → Vue: High-Complexity Pages (Deferred) + +These three Vaadin-backed admin pages require significant REST API work and/or complex UI +before a Vue migration is practical. Document here so they aren't forgotten. + +### SNMP Collections Admin (`admin/manageSnmpCollections.jsp`) + +**Current:** Vaadin iframe at `admin/admin-snmp-collections`. Powered by +`features/vaadin-snmp-events-and-metrics` (`SnmpCollectionAdminApplication`). + +**What it does:** Deep hierarchical editor for `datacollection-config.xml` — collections, +groups, MBeans, system definitions. Multi-level tree with inline forms for each node type. + +**Why deferred:** No v2 REST API for reading or writing the collection config. The existing +`/rest/config/datacollection` (v1) is read-only XML. A new writable REST service would be +needed before Vue components are meaningful. The data model is also deeply nested (collection +→ group → MBean → attribute). + +**Prerequisites before Vue migration:** +- New `DataCollectionConfigRestService` (v2) with read/write for collections, groups, MBeans +- Decide on scope: full editor vs. read-only viewer with file upload/download + +--- + +### BSM Admin (`admin/bsm/adminpage.jsp`) + +**Current:** Vaadin application (BSM-specific Vaadin module in `features/bsm/vaadin-adminpage`). + +**What it does:** Graph editor for Business Service topology — create/edit/delete business +services, add edges (IP service, reduction key, child service), set map/reduce functions, +preview impact propagation. + +**Why deferred:** No REST API for BSM CRUD that is suitable for a Vue graph editor. +The BSM domain model is complex (reduction functions, map functions, edge weights, threshold +expressions). A viable Vue replacement would need either a new REST layer or to adapt the +existing `/api/v2/businessservices` endpoints (which are read-heavy and mutation-limited). + +**Prerequisites before Vue migration:** +- Audit existing BSM REST endpoints for completeness +- Graph visualization library selection (topology map pattern, or a dedicated graph lib like vis.js) + +--- + +### Surveillance View Display (`surveillance-view.jsp`, `dashboard.jsp`) + +**Current:** `surveillance-view.jsp` embeds a Vaadin live status matrix +(`vaadin-surveillance-views`); `dashboard.jsp` is the main dashboard wrapping it. + +**What it does:** Live N×M matrix of node categories (rows × columns) showing outage/availability +status per cell. Clicking a cell shows a detail panel with nodes, alarms, notifications, graphs. +Auto-refreshes on a configurable interval. + +**Why deferred:** Substantial real-time data requirements. Each cell requires aggregating +availability data per category intersection — the existing REST (`/rest/availability`) +supports this but needs careful caching/polling. The detail panel has five sub-tables +(nodes, alarms, notifications, graphs, outages) — comparable in scope to the node detail page. + +**Prerequisites before Vue migration:** +- Verify availability API covers row×column intersection queries +- Decide on refresh strategy (polling vs. WebSocket/SSE) +- Consider whether `surveillance-view.jsp` and `dashboard.jsp` should become one Vue route + +--- + +## Dashboard Outages Widget: service name blank + +**Component:** `ui/src/components/Dashboard/widgets/OutagesWidget.vue:57` + +```vue +{{ outage.serviceName }} +``` + +**Root cause:** The OpenNMS v2 REST API (`/api/v2/outages`) serializes `OnmsOutage` +with the service info nested under `monitoredService.serviceType.name`. There is no +flat `serviceName` field in the response — `getServiceType()` is `@JsonIgnore` in the +`OnmsOutage` model. The `Outage` TypeScript type (`ui/src/types/index.ts:302`) declares +`serviceName: string` but this field will always be `undefined` at runtime. + +**Correct path:** `outage.monitoredService.serviceType.name` + +**Same issue in:** `ui/src/components/Nodes/OutagesTable.vue:26` (node detail page). + +**Fix:** Either (a) update the `Outage` TS type to reflect the nested shape and update +both templates, or (b) add a `@XmlElement(name="serviceName") @Transient` convenience +getter to `OnmsOutage.java` (like `getNodeLabel()`) that returns +`getMonitoredService().getServiceType().getName()`. + +Option (b) is smaller and consistent with the existing pattern in `OnmsOutage` +(`getNodeId`, `getNodeLabel`, `getIpAddress` are all `@Transient @XmlElement` helpers +over the same nested object graph). diff --git a/docs/superpowers/plans/2026-04-05-alarms-list-page.md b/docs/superpowers/plans/2026-04-05-alarms-list-page.md new file mode 100644 index 000000000000..b397580498ee --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-alarms-list-page.md @@ -0,0 +1,949 @@ +# Alarms List Page 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:** Build a full-featured `/alarms` Vue SPA page with filtering, sorting, pagination, and row-level ack/escalate/clear actions, and wire the Summary widget KPI cards as router-links. + +**Architecture:** Follows the `Nodes.vue` / `NodesTable.vue` pattern — a thin container (`Alarms.vue`) with breadcrumbs and layout, and a self-contained table component (`AlarmsListTable.vue`) that owns all data-fetching, filter state, sort, pagination, and row actions. No new services or types needed. + +**Tech Stack:** Vue 3 ` + + +``` + +- [ ] **Step 2: Verify build** + +```bash +cd /Users/chance/git/opennms/ui && node_modules/.bin/vite build 2>&1 | tail -5 +``` + +Expected: `✓ built in` — no type errors, no missing imports. + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/components/Alarms/AlarmsListTable.vue +git commit -m "feat(alarms): add AlarmsListTable component" +``` + +--- + +### Task 3: Build Alarms container page + +**Files:** +- Create: `ui/src/containers/Alarms.vue` + +- [ ] **Step 1: Create the container** + +Create `ui/src/containers/Alarms.vue`: + +```vue + + + + + + +``` + +- [ ] **Step 2: Verify build** + +```bash +cd /Users/chance/git/opennms/ui && node_modules/.bin/vite build 2>&1 | tail -5 +``` + +Expected: `✓ built in` — no errors. + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/containers/Alarms.vue +git commit -m "feat(alarms): add Alarms container page" +``` + +--- + +### Task 4: Wire Summary widget KPI card links + +**Files:** +- Modify: `ui/src/components/Dashboard/widgets/SummaryWidget.vue` + +The three KPI cards become `` wrappers. The `Kpi` interface gets a `to` field. The card `div` is replaced with a `` that has the same class bindings. + +- [ ] **Step 1: Update `SummaryWidget.vue`** + +Replace the entire contents of `ui/src/components/Dashboard/widgets/SummaryWidget.vue` with: + +```vue + + + + + + +``` + +- [ ] **Step 2: Verify build** + +```bash +cd /Users/chance/git/opennms/ui && node_modules/.bin/vite build 2>&1 | tail -5 +``` + +Expected: `✓ built in` — no errors. + +- [ ] **Step 3: Deploy and verify visually** + +```bash +cd /Users/chance/git/opennms/ui && ./deploy-to-container.sh 2>&1 +``` + +Expected: `Done. Live bundle: assets/index-*.js` + +Open the dashboard in the browser. Verify: +1. All three KPI cards are clickable (pointer cursor on hover, subtle shadow) +2. "Active Alarms" card navigates to `/#/alarms` +3. "Total Nodes" card navigates to `/#/nodes` +4. "Active Outages" card navigates to `/#/outages` (404 page until Outages list is built — expected) +5. The `/alarms` page loads with filter bar, table, and pagination + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/components/Dashboard/widgets/SummaryWidget.vue +git commit -m "feat(alarms): wire summary widget KPI cards as router-links" +``` diff --git a/docs/superpowers/plans/2026-04-05-availability-panel-ip-grouping.md b/docs/superpowers/plans/2026-04-05-availability-panel-ip-grouping.md new file mode 100644 index 000000000000..bb04f34ac22e --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-availability-panel-ip-grouping.md @@ -0,0 +1,83 @@ +# Availability Panel IP Grouping 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:** Reorganize the availability panel's flat service card grid into sections grouped by IP interface address. + +**Architecture:** The template's nested `v-for` is restructured so the outer loop iterates over `ipinterfaces` and renders an IP header label, while the inner loop renders service cards for that interface. The IP address is removed from individual cards since it is now carried by the section header. + +**Tech Stack:** Vue 3 (Composition API, ` + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add ui/src/components/Outages/OutagesListTable.vue +git commit -m "feat(outages): add OutagesListTable component" +``` + +--- + +## Task 4: Outages container (list page) + +**Files:** +- Create: `ui/src/containers/Outages.vue` + +- [ ] **Step 1: Create the container** + +Create `ui/src/containers/Outages.vue`: + +```vue + + + + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add ui/src/containers/Outages.vue +git commit -m "feat(outages): add Outages list container" +``` + +--- + +## Task 5: OutageDetail container (detail page) + +**Files:** +- Create: `ui/src/containers/OutageDetail.vue` + +- [ ] **Step 1: Create the container** + +Create `ui/src/containers/OutageDetail.vue`: + +```vue + + + + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add ui/src/containers/OutageDetail.vue +git commit -m "feat(outages): add OutageDetail container" +``` + +--- + +## Task 6: Add Vue router routes + +**Files:** +- Modify: `ui/src/main/router/index.ts` + +- [ ] **Step 1: Add the two routes** + +In `ui/src/main/router/index.ts`, find the block containing the existing `/alarm/:id` route (around line 180). Add the outage routes immediately after it: + +```ts + { + path: '/outages', + name: 'Outages', + component: () => import('@/containers/Outages.vue') + }, + { + path: '/outage/:id', + name: 'Outage Detail', + component: () => import('@/containers/OutageDetail.vue') + }, +``` + +- [ ] **Step 2: Commit** + +```bash +git add ui/src/main/router/index.ts +git commit -m "feat(outages): add /outages and /outage/:id routes" +``` + +--- + +## Task 7: Update menu link + +**Files:** +- Modify: `opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template-default.json` + +- [ ] **Step 1: Update the Outages menu entry** + +Find the outages entry (around line 163): + +```json +{ + "id": "outages", + "name": "Outages", + "url": "outage/index.jsp", + "locationMatch": "outage", + "roles": null +}, +``` + +Change `"url"` to: + +```json +{ + "id": "outages", + "name": "Outages", + "url": "ui/index.html#/outages", + "locationMatch": "outage", + "roles": null +}, +``` + +- [ ] **Step 2: Commit** + +```bash +git add opennms-webapp-rest/src/main/webapp/WEB-INF/menu/menu-template-default.json +git commit -m "feat(menu): point Outages menu item to Vue SPA route" +``` + +--- + +## Task 8: Build, deploy, and verify + +**Files:** None (build + deploy steps) + +- [ ] **Step 1: Build the Vue SPA** + +```bash +cd /Users/chance/git/opennms/ui +/Users/chance/git/opennms/ui/target/node/yarn/dist/bin/yarn build 2>&1 | tail -20 +``` + +Expected: exit 0, no errors. + +- [ ] **Step 2: Verify built index.html references hashed assets** + +```bash +grep -o 'assets/index-[^"]*\.js' /Users/chance/git/opennms/ui/src/main/dist/index.html +``` + +Expected: one match like `assets/index-AbCdEfGh.js`. + +- [ ] **Step 3: Verify CSS has no bare --feather-* values** + +```bash +grep -c '\-\-feather-' /Users/chance/git/opennms/ui/src/main/dist/assets/*.css || echo "0" +``` + +Expected: 0 (all `--feather-*` must be inside `var()`). + +- [ ] **Step 4: Deploy to container** + +```bash +cd /Users/chance/git/opennms/ui && ./deploy-to-container.sh test-opennms +``` + +Expected: deploy script exits 0. + +- [ ] **Step 5: Verify live bundle hash matches built hash** + +```bash +podman exec test-opennms grep -o 'assets/index-[^"]*\.js' /opt/opennms/jetty-webapps/opennms/ui/index.html +grep -o 'assets/index-[^"]*\.js' /Users/chance/git/opennms/ui/src/main/dist/index.html +``` + +Both lines must be identical. + +- [ ] **Step 6: Verify the JS bundle is served** + +```bash +HASH=$(grep -o 'assets/index-[^"]*\.js' /Users/chance/git/opennms/ui/src/main/dist/index.html) +curl -s -o /dev/null -w "%{http_code}" -L "http://localhost:8980/opennms/ui/$HASH" +``` + +Expected: `200` + +- [ ] **Step 7: Smoke-test the list route** + +```bash +curl -s -o /dev/null -w "%{http_code}" -u admin:notdefault \ + "http://localhost:8980/opennms/api/v2/outages?limit=1" +``` + +Expected: `200` or `204` (confirms the API the Vue list calls is reachable) + +- [ ] **Step 8: Tell user to verify in browser** + +Navigate to `http://localhost:8980/opennms/ui/#/outages` — verify: +- List loads with Current filter active +- Clicking a row navigates to `/outage/:id` and shows the detail card +- Node links navigate to the Vue NodeDetails page +- Menu "Outages" item navigates to the Vue list + +Hard-refresh with `Cmd+Shift+R` before testing. diff --git a/docs/superpowers/plans/2026-04-05-perses-phase1-foundation.md b/docs/superpowers/plans/2026-04-05-perses-phase1-foundation.md new file mode 100644 index 000000000000..bb4e1f5e59b3 --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-perses-phase1-foundation.md @@ -0,0 +1,1635 @@ +# Perses Phase 1 — Foundation 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:** Stand up the Java storage layer (`onms_dashboards` table + REST API) and the frontend infrastructure (OpenNMS Perses datasource plugin, React mount composable, theme bridge, `PersesPanel`/`PersesCanvas` Vue components) that all subsequent phases depend on. + +**Architecture:** A new `onms_dashboards` DB table stores Perses dashboard JSON, accessed via a JAX-RS v2 REST service. In the Vue SPA, `ReactDOM.createRoot` mounts Perses React panels into Vue-managed container divs. A theme bridge reads Feather DS CSS custom properties at mount time and builds the MUI theme. An OpenNMS-specific Perses datasource plugin translates `OpenNMSQuery` objects into `/rest/measurements` calls. + +**Tech Stack:** Java 17, Hibernate 3 (JPA annotations), CXF JAX-RS v2, Liquibase, Vue 3, TypeScript, React 18, `@perses-dev/core`, `@perses-dev/panels-plugin`, `@perses-dev/dashboards`, `@perses-dev/plugin-system`, MUI 5, Vitest + +--- + +## File Map + +**New Java files:** +- `core/schema/src/main/liquibase/36.0.0/changelog.xml` — DB migration +- `opennms-model/src/main/java/org/opennms/netmgt/model/OnmsDashboard.java` — Hibernate entity +- `opennms-dao-api/src/main/java/org/opennms/netmgt/dao/api/OnmsDashboardDao.java` — DAO interface +- `opennms-dao/src/main/java/org/opennms/netmgt/dao/hibernate/OnmsDashboardDaoHibernate.java` — Hibernate impl +- `opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/DashboardRestService.java` — JAX-RS REST +- `opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/DashboardRestServiceIT.java` — integration test + +**Modified Java files:** +- `core/schema/src/main/liquibase/changelog.xml` — add 36.0.0 include +- `opennms-dao/src/main/resources/META-INF/opennms/applicationContext-shared.xml` — register DAO bean + +**New frontend files:** +- `ui/src/datasource/opennms/types.ts` — OpenNMSQuery type +- `ui/src/datasource/opennms/client.ts` — /rest/measurements fetch client +- `ui/src/datasource/opennms/plugin.ts` — Perses DataSourcePlugin implementation +- `ui/src/datasource/opennms/QueryEditor.tsx` — React query editor component +- `ui/src/datasource/opennms/index.ts` — plugin registration export +- `ui/src/composables/usePerses.ts` — ReactDOM.createRoot mount composable +- `ui/src/theme/persesTheme.ts` — Feather DS → MUI theme bridge +- `ui/src/components/Perses/PersesPanel.vue` — single panel Vue wrapper +- `ui/src/components/Perses/PersesCanvas.vue` — full dashboard Vue wrapper + +**New frontend tests:** +- `ui/tests/datasource/opennms-client.test.ts` +- `ui/tests/datasource/opennms-plugin.test.ts` +- `ui/tests/composables/usePerses.test.ts` +- `ui/tests/theme/persesTheme.test.ts` + +**Modified frontend files:** +- `ui/package.json` — add Perses + React deps + +--- + +## Task 1: Liquibase Migration + +**Files:** +- Create: `core/schema/src/main/liquibase/36.0.0/changelog.xml` +- Modify: `core/schema/src/main/liquibase/changelog.xml` + +- [ ] **Step 1: Create the 36.0.0 directory and changelog** + +```bash +mkdir -p core/schema/src/main/liquibase/36.0.0 +``` + +Create `core/schema/src/main/liquibase/36.0.0/changelog.xml`: + +```xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +``` + +- [ ] **Step 2: Register in master changelog** + +In `core/schema/src/main/liquibase/changelog.xml`, add after the `35.0.0` include line: + +```xml + +``` + +- [ ] **Step 3: Commit** + +```bash +git add core/schema/src/main/liquibase/36.0.0/changelog.xml \ + core/schema/src/main/liquibase/changelog.xml +git commit -m "feat(schema): add onms_dashboards table for Perses dashboard storage" +``` + +--- + +## Task 2: OnmsDashboard Hibernate Entity + +**Files:** +- Create: `opennms-model/src/main/java/org/opennms/netmgt/model/OnmsDashboard.java` + +The `org.opennms.netmgt.model` package is already in the Hibernate session factory's `packagesToScan` list (`applicationContext-shared.xml:40`), so no additional registration is needed. + +- [ ] **Step 1: Create the entity** + +```java +// opennms-model/src/main/java/org/opennms/netmgt/model/OnmsDashboard.java +package org.opennms.netmgt.model; + +import java.io.Serializable; +import java.util.Date; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.Id; +import javax.persistence.PrePersist; +import javax.persistence.PreUpdate; +import javax.persistence.Table; +import javax.persistence.Temporal; +import javax.persistence.TemporalType; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlRootElement; + +import com.google.common.base.MoreObjects; + +@Entity +@Table(name = "onms_dashboards") +@XmlRootElement(name = "dashboard") +@XmlAccessorType(XmlAccessType.NONE) +public class OnmsDashboard implements Serializable { + + private static final long serialVersionUID = 1L; + + @Id + @Column(name = "id", length = 36, nullable = false) + private String id; + + @Column(name = "name", length = 255, nullable = false) + private String name; + + @Column(name = "description", length = 1024) + private String description; + + @Column(name = "spec", nullable = false, columnDefinition = "text") + private String spec; + + @Column(name = "created_by", length = 255) + private String createdBy; + + @Column(name = "created_at", nullable = false) + @Temporal(TemporalType.TIMESTAMP) + private Date createdAt; + + @Column(name = "updated_at", nullable = false) + @Temporal(TemporalType.TIMESTAMP) + private Date updatedAt; + + @PrePersist + protected void onCreate() { + final Date now = new Date(); + if (createdAt == null) createdAt = now; + updatedAt = now; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = new Date(); + } + + // --- getters / setters --- + + public String getId() { return id; } + public void setId(String id) { this.id = id; } + + public String getName() { return name; } + public void setName(String name) { this.name = name; } + + public String getDescription() { return description; } + public void setDescription(String description) { this.description = description; } + + public String getSpec() { return spec; } + public void setSpec(String spec) { this.spec = spec; } + + public String getCreatedBy() { return createdBy; } + public void setCreatedBy(String createdBy) { this.createdBy = createdBy; } + + public Date getCreatedAt() { return createdAt; } + public void setCreatedAt(Date createdAt) { this.createdAt = createdAt; } + + public Date getUpdatedAt() { return updatedAt; } + public void setUpdatedAt(Date updatedAt) { this.updatedAt = updatedAt; } + + @Override + public String toString() { + return MoreObjects.toStringHelper(this) + .add("id", id) + .add("name", name) + .toString(); + } +} +``` + +- [ ] **Step 2: Compile the model module** + +```bash +./compile.pl -DskipTests --projects :opennms-model install +``` + +Expected: `BUILD SUCCESS` + +- [ ] **Step 3: Commit** + +```bash +git add opennms-model/src/main/java/org/opennms/netmgt/model/OnmsDashboard.java +git commit -m "feat(model): add OnmsDashboard Hibernate entity" +``` + +--- + +## Task 3: OnmsDashboardDao Interface + +**Files:** +- Create: `opennms-dao-api/src/main/java/org/opennms/netmgt/dao/api/OnmsDashboardDao.java` + +- [ ] **Step 1: Create the DAO interface** + +```java +// opennms-dao-api/src/main/java/org/opennms/netmgt/dao/api/OnmsDashboardDao.java +package org.opennms.netmgt.dao.api; + +import org.opennms.netmgt.model.OnmsDashboard; + +public interface OnmsDashboardDao extends OnmsDao { +} +``` + +- [ ] **Step 2: Compile** + +```bash +./compile.pl -DskipTests --projects :opennms-dao-api install +``` + +Expected: `BUILD SUCCESS` + +- [ ] **Step 3: Commit** + +```bash +git add opennms-dao-api/src/main/java/org/opennms/netmgt/dao/api/OnmsDashboardDao.java +git commit -m "feat(dao-api): add OnmsDashboardDao interface" +``` + +--- + +## Task 4: OnmsDashboardDaoHibernate + Spring Registration + +**Files:** +- Create: `opennms-dao/src/main/java/org/opennms/netmgt/dao/hibernate/OnmsDashboardDaoHibernate.java` +- Modify: `opennms-dao/src/main/resources/META-INF/opennms/applicationContext-shared.xml` + +- [ ] **Step 1: Create the Hibernate implementation** + +```java +// opennms-dao/src/main/java/org/opennms/netmgt/dao/hibernate/OnmsDashboardDaoHibernate.java +package org.opennms.netmgt.dao.hibernate; + +import org.opennms.netmgt.dao.api.OnmsDashboardDao; +import org.opennms.netmgt.model.OnmsDashboard; + +public class OnmsDashboardDaoHibernate extends AbstractDaoHibernate + implements OnmsDashboardDao { + + public OnmsDashboardDaoHibernate() { + super(OnmsDashboard.class); + } +} +``` + +- [ ] **Step 2: Register the Spring bean** + +In `opennms-dao/src/main/resources/META-INF/opennms/applicationContext-shared.xml`, add after the `memoDao` bean (around line 122): + +```xml + + + + +``` + +- [ ] **Step 3: Compile the dao module** + +```bash +./compile.pl -DskipTests --projects :opennms-dao install +``` + +Expected: `BUILD SUCCESS` + +- [ ] **Step 4: Commit** + +```bash +git add opennms-dao/src/main/java/org/opennms/netmgt/dao/hibernate/OnmsDashboardDaoHibernate.java \ + opennms-dao/src/main/resources/META-INF/opennms/applicationContext-shared.xml +git commit -m "feat(dao): add OnmsDashboardDaoHibernate + Spring bean registration" +``` + +--- + +## Task 5: DashboardRestService + +**Files:** +- Create: `opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/DashboardRestService.java` + +The CXF v2 server (`applicationContext-cxf-rest-v2.xml:74`) auto-discovers all `@Component @Path` classes in `org.opennms.web.rest.v2` — no XML registration needed. + +- [ ] **Step 1: Create the REST service** + +```java +// opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/DashboardRestService.java +package org.opennms.web.rest.v2; + +import java.net.URI; +import java.util.Date; +import java.util.List; +import java.util.UUID; +import java.util.stream.Collectors; + +import javax.ws.rs.Consumes; +import javax.ws.rs.DELETE; +import javax.ws.rs.GET; +import javax.ws.rs.POST; +import javax.ws.rs.PUT; +import javax.ws.rs.Path; +import javax.ws.rs.PathParam; +import javax.ws.rs.Produces; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.SecurityContext; +import javax.ws.rs.core.UriInfo; + +import io.swagger.v3.oas.annotations.tags.Tag; +import org.opennms.netmgt.dao.api.OnmsDashboardDao; +import org.opennms.netmgt.model.OnmsDashboard; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@Path("dashboards") +@Transactional +@Tag(name = "Dashboards", description = "Perses Dashboard Storage API") +public class DashboardRestService { + + private static final Logger LOG = LoggerFactory.getLogger(DashboardRestService.class); + + @Autowired + private OnmsDashboardDao dashboardDao; + + /** List all dashboards — returns lightweight summaries (no spec). */ + @GET + @Produces(MediaType.APPLICATION_JSON) + public Response list() { + final List summaries = dashboardDao.findAll().stream() + .map(DashboardSummary::from) + .collect(Collectors.toList()); + return Response.ok(summaries).build(); + } + + /** Get one dashboard including full spec. */ + @GET + @Path("{id}") + @Produces(MediaType.APPLICATION_JSON) + public Response get(@PathParam("id") final String id) { + final OnmsDashboard dashboard = dashboardDao.get(id); + if (dashboard == null) { + return Response.status(Response.Status.NOT_FOUND).build(); + } + return Response.ok(dashboard).build(); + } + + /** Create a new dashboard. */ + @POST + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response create(@Context final UriInfo uriInfo, + @Context final SecurityContext securityContext, + final OnmsDashboard dashboard) { + dashboard.setId(UUID.randomUUID().toString()); + dashboard.setCreatedBy(securityContext.getUserPrincipal() != null + ? securityContext.getUserPrincipal().getName() : "anonymous"); + dashboard.setCreatedAt(new Date()); + dashboard.setUpdatedAt(new Date()); + dashboardDao.save(dashboard); + final URI location = uriInfo.getAbsolutePathBuilder() + .path(dashboard.getId()).build(); + return Response.created(location).entity(dashboard).build(); + } + + /** Replace a dashboard spec. */ + @PUT + @Path("{id}") + @Consumes(MediaType.APPLICATION_JSON) + @Produces(MediaType.APPLICATION_JSON) + public Response update(@PathParam("id") final String id, + final OnmsDashboard incoming) { + final OnmsDashboard existing = dashboardDao.get(id); + if (existing == null) { + return Response.status(Response.Status.NOT_FOUND).build(); + } + existing.setName(incoming.getName()); + existing.setDescription(incoming.getDescription()); + existing.setSpec(incoming.getSpec()); + existing.setUpdatedAt(new Date()); + dashboardDao.saveOrUpdate(existing); + return Response.ok(existing).build(); + } + + /** Delete a dashboard. */ + @DELETE + @Path("{id}") + public Response delete(@PathParam("id") final String id) { + final OnmsDashboard dashboard = dashboardDao.get(id); + if (dashboard == null) { + return Response.status(Response.Status.NOT_FOUND).build(); + } + dashboardDao.delete(dashboard); + return Response.noContent().build(); + } + + // --- Summary DTO (no spec field) --- + + public static class DashboardSummary { + public String id; + public String name; + public String description; + public String createdBy; + public Date createdAt; + public Date updatedAt; + + public static DashboardSummary from(final OnmsDashboard d) { + final DashboardSummary s = new DashboardSummary(); + s.id = d.getId(); + s.name = d.getName(); + s.description = d.getDescription(); + s.createdBy = d.getCreatedBy(); + s.createdAt = d.getCreatedAt(); + s.updatedAt = d.getUpdatedAt(); + return s; + } + } +} +``` + +- [ ] **Step 2: Compile webapp-rest** + +```bash +./compile.pl -DskipTests --projects :opennms-webapp-rest install +``` + +Expected: `BUILD SUCCESS` + +- [ ] **Step 3: Commit** + +```bash +git add opennms-webapp-rest/src/main/java/org/opennms/web/rest/v2/DashboardRestService.java +git commit -m "feat(rest): add DashboardRestService at /rest/dashboards" +``` + +--- + +## Task 6: DashboardRestService Integration Test + +**Files:** +- Create: `opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/DashboardRestServiceIT.java` + +- [ ] **Step 1: Write the failing test** + +```java +// opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/DashboardRestServiceIT.java +package org.opennms.web.rest.v2; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.not; + +import javax.ws.rs.core.MediaType; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.opennms.core.test.MockLogAppender; +import org.opennms.core.test.OpenNMSJUnit4ClassRunner; +import org.opennms.core.test.db.annotations.JUnitTemporaryDatabase; +import org.opennms.core.test.rest.AbstractSpringJerseyRestTestCase; +import org.opennms.test.JUnitConfigurationEnvironment; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.web.WebAppConfiguration; + +@RunWith(OpenNMSJUnit4ClassRunner.class) +@WebAppConfiguration +@ContextConfiguration(locations={ + "classpath:/META-INF/opennms/applicationContext-soa.xml", + "classpath:/META-INF/opennms/applicationContext-commonConfigs.xml", + "classpath:/META-INF/opennms/applicationContext-minimal-conf.xml", + "classpath:/META-INF/opennms/applicationContext-dao.xml", + "classpath:/META-INF/opennms/applicationContext-mockConfigManager.xml", + "classpath:/META-INF/opennms/applicationContext-databasePopulator.xml", + "classpath:/META-INF/opennms/applicationContext-mockEventIpcManager.xml", + "classpath:META-INF/opennms/applicationContext-jersey-test.xml" +}) +@JUnitConfigurationEnvironment +@JUnitTemporaryDatabase +public class DashboardRestServiceIT extends AbstractSpringJerseyRestTestCase { + + @Override + protected void afterServletStart() { + MockLogAppender.setupLogging(true, "DEBUG"); + } + + @Test + public void testCreateAndRetrieve() throws Exception { + // Create + final String body = "{\"name\":\"My Dashboard\",\"description\":\"test\",\"spec\":\"{\\\"panels\\\":[]}\"}"; + final String location = sendPost("/dashboards", body, 201, MediaType.APPLICATION_JSON); + assertThat(location, containsString("/rest/dashboards/")); + + // Extract ID from Location header + final String id = location.substring(location.lastIndexOf('/') + 1); + + // GET by ID — spec is present + final String json = sendRequest(GET, "/dashboards/" + id, 200); + assertThat(json, containsString("My Dashboard")); + assertThat(json, containsString("panels")); + + // GET list — spec is NOT present in summary + final String list = sendRequest(GET, "/dashboards", 200); + assertThat(list, containsString("My Dashboard")); + assertThat(list, not(containsString("panels"))); + } + + @Test + public void testUpdateAndDelete() throws Exception { + final String body = "{\"name\":\"ToUpdate\",\"spec\":\"{}\"}"; + final String location = sendPost("/dashboards", body, 201, MediaType.APPLICATION_JSON); + final String id = location.substring(location.lastIndexOf('/') + 1); + + // Update + final String updated = "{\"name\":\"Updated\",\"spec\":\"{\\\"v\\\":2}\"}"; + sendRequest(PUT, "/dashboards/" + id, updated, 200); + final String json = sendRequest(GET, "/dashboards/" + id, 200); + assertThat(json, containsString("Updated")); + + // Delete + sendRequest(DELETE, "/dashboards/" + id, 204); + sendRequest(GET, "/dashboards/" + id, 404); + } +} +``` + +- [ ] **Step 2: Run the test — verify it fails (no table yet in test DB)** + +```bash +cd opennms-webapp-rest && \ + ../../maven/bin/mvn test -Dtest=DashboardRestServiceIT -t 1 +``` + +Expected: test failure due to missing table (Liquibase hasn't run in test context yet). If test runner auto-applies Liquibase, the test may pass. Either way, note the output. + +- [ ] **Step 3: Run with full dependency build to ensure Liquibase applies** + +```bash +./compile.pl -t --projects :opennms-webapp-rest install +``` + +Expected: `DashboardRestServiceIT` PASSES. Build SUCCESS. + +- [ ] **Step 4: Commit** + +```bash +git add opennms-webapp-rest/src/test/java/org/opennms/web/rest/v2/DashboardRestServiceIT.java +git commit -m "test(rest): add DashboardRestServiceIT integration tests" +``` + +--- + +## Task 7: Install Perses + React Dependencies + +**Files:** +- Modify: `ui/package.json` + +- [ ] **Step 1: Add dependencies** + +In `ui/package.json`, add to `"dependencies"`: + +```json + "@emotion/react": "^11.13.0", + "@emotion/styled": "^11.13.0", + "@mui/material": "^5.16.0", + "@perses-dev/core": "^0.51.0", + "@perses-dev/components": "^0.51.0", + "@perses-dev/dashboards": "^0.51.0", + "@perses-dev/panels-plugin": "^0.51.0", + "@perses-dev/plugin-system": "^0.51.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", +``` + +Add to `"devDependencies"`: + +```json + "@types/react": "^18.3.1", + "@types/react-dom": "^18.3.0", +``` + +- [ ] **Step 2: Install** + +```bash +cd ui && yarn install +``` + +Expected: lockfile updated, no peer dependency errors. If Perses pins an incompatible MUI version, adjust `@mui/material` to match what Perses requires (check `node_modules/@perses-dev/components/package.json`). + +- [ ] **Step 3: Verify TypeScript can import Perses types** + +```bash +cd ui && node -e "const p = require('@perses-dev/core'); console.log(Object.keys(p).slice(0,5))" +``` + +Expected: prints some exported type names without error. + +- [ ] **Step 4: Commit** + +```bash +git add ui/package.json ui/yarn.lock +git commit -m "feat(ui): add @perses-dev/* and react 18 dependencies" +``` + +--- + +## Task 8: OpenNMS Datasource Types + +**Files:** +- Create: `ui/src/datasource/opennms/types.ts` + +- [ ] **Step 1: Create the types file** + +```typescript +// ui/src/datasource/opennms/types.ts + +/** A single metric query sent to /rest/measurements */ +export interface OpenNMSQuerySpec { + /** e.g. "node[1].interfaceSnmp[eth0-000000000000]" */ + resourceId: string + /** RRD attribute name, e.g. "ifInOctets" */ + attribute: string + /** Aggregation function */ + aggregation: 'AVERAGE' | 'MIN' | 'MAX' | 'LAST' + /** Optional label override (defaults to attribute) */ + label?: string + /** JEXL expression — present on CDEF metrics, absent on DEF metrics */ + expression?: string + /** If true, fetch but don't render (used in CDEF chains) */ + transient?: boolean +} + +/** Shape of a single source entry in the /rest/measurements request body */ +export interface MeasurementsSource { + aggregation: string + attribute: string + label: string + resourceId: string + transient: boolean +} + +/** Shape of a JEXL expression entry in the /rest/measurements request body */ +export interface MeasurementsExpression { + value: string + label: string + transient: boolean +} + +/** POST body for /rest/measurements */ +export interface MeasurementsPayload { + start: number + end: number + step: number + source: MeasurementsSource[] + expression?: MeasurementsExpression[] +} + +/** Column of values in the /rest/measurements response */ +export interface MeasurementsColumn { + values: (number | null)[] +} + +/** Response from /rest/measurements */ +export interface MeasurementsResponse { + start: number + end: number + step: number + timestamps: number[] + labels: string[] + columns: MeasurementsColumn[] +} +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +```bash +cd ui && yarn vue-tsc --noEmit 2>&1 | grep -i "datasource" || echo "OK" +``` + +Expected: no errors mentioning `datasource/opennms/types.ts` + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/datasource/opennms/types.ts +git commit -m "feat(datasource): add OpenNMS query and measurements types" +``` + +--- + +## Task 9: OpenNMS Measurements Client + +**Files:** +- Create: `ui/src/datasource/opennms/client.ts` +- Create: `ui/tests/datasource/opennms-client.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// ui/tests/datasource/opennms-client.test.ts +import { describe, test, expect, vi, beforeEach } from 'vitest' +import { fetchMeasurements } from '@/datasource/opennms/client' +import type { MeasurementsPayload, MeasurementsResponse } from '@/datasource/opennms/types' + +const mockResponse: MeasurementsResponse = { + start: 1000000, + end: 2000000, + step: 300000, + timestamps: [1000000, 1300000, 1600000, 1900000], + labels: ['ifInOctets'], + columns: [{ values: [10.0, 20.0, 15.0, 25.0] }] +} + +describe('fetchMeasurements', () => { + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ + ok: true, + json: () => Promise.resolve(mockResponse) + })) + }) + + test('POSTs to /rest/measurements with correct body', async () => { + const payload: MeasurementsPayload = { + start: 1000000, + end: 2000000, + step: 300000, + source: [{ aggregation: 'AVERAGE', attribute: 'ifInOctets', label: 'ifInOctets', resourceId: 'node[1].interfaceSnmp[eth0]', transient: false }] + } + + const result = await fetchMeasurements(payload) + + expect(fetch).toHaveBeenCalledWith('/rest/measurements', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' }, + body: JSON.stringify(payload) + }) + expect(result.labels).toEqual(['ifInOctets']) + expect(result.columns[0].values).toHaveLength(4) + }) + + test('throws on HTTP error', async () => { + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 })) + await expect(fetchMeasurements({ start: 0, end: 1, step: 300000, source: [] })) + .rejects.toThrow('Measurements request failed: 500') + }) +}) +``` + +- [ ] **Step 2: Run failing test** + +```bash +cd ui && yarn test tests/datasource/opennms-client.test.ts +``` + +Expected: FAIL — `fetchMeasurements` not found + +- [ ] **Step 3: Implement the client** + +```typescript +// ui/src/datasource/opennms/client.ts +import type { MeasurementsPayload, MeasurementsResponse } from './types' + +/** + * POST to /rest/measurements and return the response. + * Auth is handled by session cookies (same-origin request). + */ +export async function fetchMeasurements(payload: MeasurementsPayload): Promise { + const response = await fetch('/rest/measurements', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: JSON.stringify(payload) + }) + + if (!response.ok) { + throw new Error(`Measurements request failed: ${response.status}`) + } + + return response.json() as Promise +} +``` + +- [ ] **Step 4: Run passing test** + +```bash +cd ui && yarn test tests/datasource/opennms-client.test.ts +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/datasource/opennms/client.ts \ + ui/tests/datasource/opennms-client.test.ts +git commit -m "feat(datasource): add fetchMeasurements client with tests" +``` + +--- + +## Task 10: OpenNMS Perses Datasource Plugin + +**Files:** +- Create: `ui/src/datasource/opennms/plugin.ts` +- Create: `ui/tests/datasource/opennms-plugin.test.ts` + +This implements the Perses `TimeSeriesQueryPlugin` interface. The plugin receives an `OpenNMSQuerySpec` plus time range context, fetches measurements, and returns `TimeSeriesData`. + +- [ ] **Step 1: Write the failing test** + +```typescript +// ui/tests/datasource/opennms-plugin.test.ts +import { describe, test, expect, vi, beforeEach } from 'vitest' +import { OpenNMSTimeSeriesQueryPlugin } from '@/datasource/opennms/plugin' +import * as client from '@/datasource/opennms/client' +import type { MeasurementsResponse } from '@/datasource/opennms/types' + +const mockMeasurements: MeasurementsResponse = { + start: 1700000000000, + end: 1700003600000, + step: 300000, + timestamps: [1700000000000, 1700000300000], + labels: ['ifInOctets'], + columns: [{ values: [100.0, 200.0] }] +} + +describe('OpenNMSTimeSeriesQueryPlugin', () => { + beforeEach(() => { + vi.spyOn(client, 'fetchMeasurements').mockResolvedValue(mockMeasurements) + }) + + test('maps measurements response to Perses TimeSeriesData', async () => { + const result = await OpenNMSTimeSeriesQueryPlugin.getTimeSeriesData( + { + resourceId: 'node[1].interfaceSnmp[eth0]', + attribute: 'ifInOctets', + aggregation: 'AVERAGE' + }, + { + timeRange: { start: new Date(1700000000000), end: new Date(1700003600000) }, + suggestedStepMs: 300000, + datasource: undefined + } + ) + + expect(result.series).toHaveLength(1) + expect(result.series[0].name).toBe('ifInOctets') + // Perses values are [timestamp_seconds, value] + expect(result.series[0].values[0]).toEqual([1700000000, 100.0]) + expect(result.series[0].values[1]).toEqual([1700000300, 200.0]) + }) + + test('passes null values through as null', async () => { + vi.spyOn(client, 'fetchMeasurements').mockResolvedValue({ + ...mockMeasurements, + columns: [{ values: [null, 200.0] }] + }) + + const result = await OpenNMSTimeSeriesQueryPlugin.getTimeSeriesData( + { resourceId: 'x', attribute: 'y', aggregation: 'AVERAGE' }, + { timeRange: { start: new Date(1700000000000), end: new Date(1700003600000) }, suggestedStepMs: 300000, datasource: undefined } + ) + + expect(result.series[0].values[0][1]).toBeNull() + }) +}) +``` + +- [ ] **Step 2: Run failing test** + +```bash +cd ui && yarn test tests/datasource/opennms-plugin.test.ts +``` + +Expected: FAIL + +- [ ] **Step 3: Implement the plugin** + +```typescript +// ui/src/datasource/opennms/plugin.ts +import type { TimeSeriesQueryPlugin, TimeSeriesData, TimeSeriesQueryContext } from '@perses-dev/core' +import { fetchMeasurements } from './client' +import type { OpenNMSQuerySpec, MeasurementsPayload, MeasurementsSource } from './types' + +export const OpenNMSTimeSeriesQueryPlugin: TimeSeriesQueryPlugin = { + async getTimeSeriesData( + spec: OpenNMSQuerySpec, + context: TimeSeriesQueryContext + ): Promise { + const { timeRange, suggestedStepMs } = context + const start = timeRange.start.getTime() + const end = timeRange.end.getTime() + const step = Math.max(suggestedStepMs ?? 300000, 60000) + + const payload: MeasurementsPayload = { + start, + end, + step, + source: [] + } + + if (spec.expression) { + // CDEF metric — use expression array + payload.source = [] + payload.expression = [{ + value: spec.expression, + label: spec.label ?? spec.attribute, + transient: spec.transient ?? false + }] + } else { + const source: MeasurementsSource = { + aggregation: spec.aggregation, + attribute: spec.attribute, + label: spec.label ?? spec.attribute, + resourceId: spec.resourceId, + transient: spec.transient ?? false + } + payload.source = [source] + } + + const response = await fetchMeasurements(payload) + + // Map to Perses TimeSeriesData format + // Perses expects values as [unix_timestamp_seconds, value | null][] + const series = response.labels.map((label, colIdx) => ({ + name: label, + values: response.timestamps.map((ts, rowIdx) => [ + ts / 1000, // ms → seconds + response.columns[colIdx]?.values[rowIdx] ?? null + ] as [number, number | null]) + })) + + return { + timeRange: { start: timeRange.start, end: timeRange.end }, + series + } + } +} +``` + +- [ ] **Step 4: Run passing test** + +```bash +cd ui && yarn test tests/datasource/opennms-plugin.test.ts +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/datasource/opennms/plugin.ts \ + ui/tests/datasource/opennms-plugin.test.ts +git commit -m "feat(datasource): implement OpenNMS Perses TimeSeriesQueryPlugin" +``` + +--- + +## Task 11: QueryEditor React Component + +**Files:** +- Create: `ui/src/datasource/opennms/QueryEditor.tsx` + +This is the React component Perses renders in the dashboard editor's query section. It lets users pick a resource and metric. + +Note: Ensure `ui/tsconfig.json` (or `vite.config.ts`) has `"jsx": "react-jsx"` or the equivalent Vite React plugin configured. + +- [ ] **Step 1: Add React JSX support to Vite config** + +Check `ui/vite.config.ts`. If `@vitejs/plugin-vue` is present but `@vitejs/plugin-react` is not, add it: + +```bash +cd ui && yarn add -D @vitejs/plugin-react +``` + +In `ui/vite.config.ts`, add: + +```typescript +import react from '@vitejs/plugin-react' + +export default defineConfig({ + plugins: [ + vue(), + react() // add this line + ], + // ... +}) +``` + +- [ ] **Step 2: Create the QueryEditor component** + +```tsx +// ui/src/datasource/opennms/QueryEditor.tsx +import React, { useState } from 'react' +import TextField from '@mui/material/TextField' +import MenuItem from '@mui/material/MenuItem' +import Box from '@mui/material/Box' +import type { OpenNMSQuerySpec } from './types' + +interface QueryEditorProps { + value: OpenNMSQuerySpec + onChange: (spec: OpenNMSQuerySpec) => void +} + +const AGGREGATIONS = ['AVERAGE', 'MIN', 'MAX', 'LAST'] as const + +export const QueryEditor: React.FC = ({ value, onChange }) => { + const [spec, setSpec] = useState(value) + + const update = (patch: Partial) => { + const updated = { ...spec, ...patch } + setSpec(updated) + onChange(updated) + } + + return ( + + update({ resourceId: e.target.value })} + helperText="e.g. node[1].interfaceSnmp[eth0-000000000000]" + fullWidth + /> + update({ attribute: e.target.value })} + helperText="e.g. ifInOctets" + fullWidth + /> + update({ aggregation: e.target.value as OpenNMSQuerySpec['aggregation'] })} + fullWidth + > + {AGGREGATIONS.map(agg => ( + {agg} + ))} + + update({ label: e.target.value || undefined })} + fullWidth + /> + + ) +} +``` + +- [ ] **Step 3: Verify TypeScript compiles** + +```bash +cd ui && yarn vue-tsc --noEmit 2>&1 | grep "QueryEditor" || echo "OK" +``` + +Expected: no errors + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/datasource/opennms/QueryEditor.tsx ui/vite.config.ts ui/package.json ui/yarn.lock +git commit -m "feat(datasource): add QueryEditor React component + Vite React plugin" +``` + +--- + +## Task 12: Datasource Plugin Registration + +**Files:** +- Create: `ui/src/datasource/opennms/index.ts` + +- [ ] **Step 1: Create the index** + +```typescript +// ui/src/datasource/opennms/index.ts +import { QueryEditor } from './QueryEditor' +import { OpenNMSTimeSeriesQueryPlugin } from './plugin' + +export const OPENNMS_DATASOURCE_KIND = 'OpenNMSTimeSeries' as const + +/** + * Perses plugin definition for OpenNMS time series queries. + * Register this with the Perses PluginRegistry at app startup. + */ +export const OpenNMSPlugin = { + kind: OPENNMS_DATASOURCE_KIND, + plugin: OpenNMSTimeSeriesQueryPlugin, + queryEditor: QueryEditor +} + +export { OpenNMSTimeSeriesQueryPlugin } from './plugin' +export { QueryEditor } from './QueryEditor' +export type { OpenNMSQuerySpec } from './types' +``` + +- [ ] **Step 2: Commit** + +```bash +git add ui/src/datasource/opennms/index.ts +git commit -m "feat(datasource): add OpenNMS Perses plugin registration index" +``` + +--- + +## Task 13: usePerses Composable + +**Files:** +- Create: `ui/src/composables/usePerses.ts` +- Create: `ui/tests/composables/usePerses.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// ui/tests/composables/usePerses.test.ts +import { describe, test, expect, vi, beforeEach, afterEach } from 'vitest' +import { ref, nextTick } from 'vue' +import { mount } from '@vue/test-utils' +import { defineComponent } from 'vue' + +// Mock ReactDOM to avoid JSDOM React rendering issues in tests +vi.mock('react-dom/client', () => ({ + createRoot: vi.fn(() => ({ + render: vi.fn(), + unmount: vi.fn() + })) +})) +vi.mock('react', () => ({ + createElement: vi.fn(() => ({})), + default: { createElement: vi.fn(() => ({})) } +})) + +import { usePerses } from '@/composables/usePerses' +import * as ReactDOM from 'react-dom/client' + +describe('usePerses', () => { + test('creates root when container ref is set', async () => { + const containerRef = ref(null) + const specRef = ref({ kind: 'TimeSeriesChart', spec: {} }) + + mount(defineComponent({ + setup() { + usePerses(containerRef, specRef, vi.fn()) + return {} + }, + template: '
' + })) + + const div = document.createElement('div') + containerRef.value = div + await nextTick() + + expect(ReactDOM.createRoot).toHaveBeenCalledWith(div) + }) + + test('unmounts on component unmount', async () => { + const mockUnmount = vi.fn() + vi.mocked(ReactDOM.createRoot).mockReturnValue({ render: vi.fn(), unmount: mockUnmount } as any) + + const containerRef = ref(null) + const specRef = ref({ kind: 'TimeSeriesChart', spec: {} }) + + const wrapper = mount(defineComponent({ + setup() { + usePerses(containerRef, specRef, vi.fn()) + return {} + }, + template: '
' + })) + + containerRef.value = document.createElement('div') + await nextTick() + + wrapper.unmount() + expect(mockUnmount).toHaveBeenCalled() + }) +}) +``` + +- [ ] **Step 2: Run failing test** + +```bash +cd ui && yarn test tests/composables/usePerses.test.ts +``` + +Expected: FAIL + +- [ ] **Step 3: Implement the composable** + +```typescript +// ui/src/composables/usePerses.ts +import { watch, onUnmounted, type Ref } from 'vue' +import { createRoot, type Root } from 'react-dom/client' +import { createElement, type ComponentType } from 'react' + +type PanelSpec = { kind: string; spec: Record } + +/** + * Mounts a React Perses component into a Vue-managed container element. + * + * @param containerRef - ref to the DOM element to mount into + * @param specRef - reactive panel spec; re-renders React when changed + * @param renderFn - called with (createElement, spec) — returns the React element to render + * + * Usage: + * const containerRef = ref(null) + * usePerses(containerRef, specRef, (h, spec) => h(PersesPanel, { spec, datasource })) + */ +export function usePerses( + containerRef: Ref, + specRef: Ref, + renderFn: (spec: PanelSpec) => ReturnType +): void { + let root: Root | null = null + + watch([containerRef, specRef], ([el, spec]) => { + if (!el) return + if (!root) { + root = createRoot(el) + } + root.render(renderFn(spec)) + }, { deep: true }) + + onUnmounted(() => { + root?.unmount() + root = null + }) +} +``` + +- [ ] **Step 4: Run passing test** + +```bash +cd ui && yarn test tests/composables/usePerses.test.ts +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/composables/usePerses.ts \ + ui/tests/composables/usePerses.test.ts +git commit -m "feat(composable): add usePerses ReactDOM mount composable" +``` + +--- + +## Task 14: Feather DS → MUI Theme Bridge + +**Files:** +- Create: `ui/src/theme/persesTheme.ts` +- Create: `ui/tests/theme/persesTheme.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// ui/tests/theme/persesTheme.test.ts +import { describe, test, expect, vi, beforeEach } from 'vitest' +import { buildPersesTheme } from '@/theme/persesTheme' + +describe('buildPersesTheme', () => { + beforeEach(() => { + // Mock getComputedStyle to return known Feather DS values + vi.stubGlobal('getComputedStyle', () => ({ + getPropertyValue: (prop: string) => { + const map: Record = { + '--feather-color-scheme': ' light', + '--feather-primary-interactive-default': ' #6200ee', + '--feather-background': ' #ffffff', + '--feather-surface-fill': ' #f5f5f5', + '--feather-text-color': ' #212121' + } + return map[prop] ?? '' + } + })) + }) + + test('builds light theme from Feather CSS vars', () => { + const theme = buildPersesTheme() + expect(theme.palette.mode).toBe('light') + expect(theme.palette.primary?.main).toBe('#6200ee') + expect(theme.palette.background?.default).toBe('#ffffff') + }) + + test('returns dark theme when open-dark class is set', () => { + vi.stubGlobal('getComputedStyle', () => ({ + getPropertyValue: (prop: string) => (prop === '--feather-color-scheme' ? ' dark' : ' #000000') + })) + const theme = buildPersesTheme() + expect(theme.palette.mode).toBe('dark') + }) +}) +``` + +- [ ] **Step 2: Run failing test** + +```bash +cd ui && yarn test tests/theme/persesTheme.test.ts +``` + +Expected: FAIL + +- [ ] **Step 3: Implement the theme bridge** + +```typescript +// ui/src/theme/persesTheme.ts +import { createTheme, type Theme } from '@mui/material/styles' + +/** + * Builds a Perses/MUI theme by reading Feather DS CSS custom properties + * from the computed style of document.body at call time. + * + * Call this at React subtree mount time (and re-call when dark mode changes). + */ +export function buildPersesTheme(): Theme { + const style = getComputedStyle(document.body) + const get = (prop: string) => style.getPropertyValue(prop).trim() + + const mode = get('--feather-color-scheme') === 'dark' ? 'dark' : 'light' + + return createTheme({ + palette: { + mode, + primary: { + main: get('--feather-primary-interactive-default') || '#1976d2' + }, + background: { + default: get('--feather-background') || (mode === 'dark' ? '#121212' : '#ffffff'), + paper: get('--feather-surface-fill') || (mode === 'dark' ? '#1e1e1e' : '#f5f5f5') + }, + text: { + primary: get('--feather-text-color') || (mode === 'dark' ? '#ffffff' : '#212121') + } + } + }) +} +``` + +- [ ] **Step 4: Run passing test** + +```bash +cd ui && yarn test tests/theme/persesTheme.test.ts +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/theme/persesTheme.ts \ + ui/tests/theme/persesTheme.test.ts +git commit -m "feat(theme): add Feather DS → MUI theme bridge for Perses" +``` + +--- + +## Task 15: PersesPanel Vue Component + +**Files:** +- Create: `ui/src/components/Perses/PersesPanel.vue` + +Mounts a single Perses `TimeSeriesChart` panel (React) into a Vue-managed div. Used by Resource Graphs (Phase 2). + +- [ ] **Step 1: Create the component** + +```vue + + + + + + +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +```bash +cd ui && yarn vue-tsc --noEmit 2>&1 | grep "PersesPanel" || echo "OK" +``` + +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/components/Perses/PersesPanel.vue +git commit -m "feat(components): add PersesPanel Vue component (React mount)" +``` + +--- + +## Task 16: PersesCanvas Vue Component + +**Files:** +- Create: `ui/src/components/Perses/PersesCanvas.vue` + +Mounts the full Perses `DashboardProvider` + `Dashboard` React tree. Used by the Dashboards pages (Phase 3). + +- [ ] **Step 1: Create the component** + +```vue + + + + + + +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +```bash +cd ui && yarn vue-tsc --noEmit 2>&1 | grep "PersesCanvas" || echo "OK" +``` + +Expected: no errors + +- [ ] **Step 3: Run all frontend tests** + +```bash +cd ui && yarn test +``` + +Expected: all tests pass + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/components/Perses/PersesCanvas.vue +git commit -m "feat(components): add PersesCanvas Vue component (full dashboard runtime)" +``` + +--- + +## Phase 1 Complete + +At this point: +- `onms_dashboards` table exists and is schema-managed by Liquibase +- `/rest/dashboards` CRUD endpoints are live and tested +- The OpenNMS Perses datasource plugin can translate `OpenNMSQuerySpec` → `/rest/measurements` → `TimeSeriesData` +- React panels can be mounted in Vue components via `usePerses` +- `PersesPanel` and `PersesCanvas` are ready for use in Phases 2 and 3 + +Proceed to `2026-04-05-perses-phase2-resource-graphs.md`. diff --git a/docs/superpowers/plans/2026-04-05-perses-phase2-resource-graphs.md b/docs/superpowers/plans/2026-04-05-perses-phase2-resource-graphs.md new file mode 100644 index 000000000000..4a46f3e65d33 --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-perses-phase2-resource-graphs.md @@ -0,0 +1,545 @@ +# Perses Phase 2 — Resource Graphs 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 Chart.js rendering in the Vue SPA Resource Graphs page with Perses `TimeSeriesChart` panels by updating `RrdGraphConverter` to emit `PersesGraphSpec` and replacing `Graph.vue`'s canvas+Chart.js setup with ``. + +**Architecture:** `RrdGraphConverter` gains a second output type (`PersesGraphSpec`) alongside the existing `ConvertedGraphData`. `Graph.vue` is refactored to use `` with the converted spec. `HtmlLegendPlugin`, `LegendFormatter`, and `chartjs-plugin-zoom` are removed. The `GraphDataTable` tab is retained as-is. + +**Prerequisites:** Phase 1 complete (`PersesPanel.vue` and the OpenNMS datasource plugin are in place). + +**Tech Stack:** Vue 3, TypeScript, `@perses-dev/panels-plugin`, Vitest + +--- + +## File Map + +**Modified:** +- `ui/src/components/Resources/utils/RrdGraphConverter.class.ts` — add `PersesGraphSpec` output +- `ui/src/components/Resources/Graph.vue` — replace Chart.js with `` +- `ui/package.json` — remove `chart.js`, `chartjs-plugin-zoom` from dependencies + +**Deleted:** +- `ui/src/components/Resources/plugins/HtmlLegendPlugin.ts` +- `ui/src/components/Resources/utils/LegendFormatter.ts` + +**New tests:** +- `ui/tests/components/Resources/RrdGraphConverter.perses.test.ts` + +--- + +## Task 1: Add PersesGraphSpec Types + +**Files:** +- Modify: `ui/src/types/index.ts` (or wherever `ConvertedGraphData` is defined — check `ui/src/types/`) + +- [ ] **Step 1: Find where graph types are defined** + +```bash +grep -rn "ConvertedGraphData\|PrintStatement\|Series" ui/src/types/ | head -10 +``` + +Note the file path (likely `ui/src/types/index.ts`). + +- [ ] **Step 2: Add PersesGraphSpec to the types file** + +In `ui/src/types/index.ts` (or wherever `ConvertedGraphData` is defined), add: + +```typescript +import type { OpenNMSQuerySpec } from '@/datasource/opennms' + +/** Visual config for a single series in a Perses TimeSeriesChart */ +export interface PersesSeriesOverride { + /** Matches the query label */ + name: string + color?: string + type?: 'line' | 'area' | 'stack' +} + +/** + * Output of RrdGraphConverter when targeting Perses rendering. + * Replaces the old ConvertedGraphData → Chart.js pipeline. + */ +export interface PersesGraphSpec { + /** Panel title */ + title: string + /** Y-axis label (from VERTICAL_LABEL in graph def) */ + yAxisLabel: string + /** One query per DEF/CDEF metric */ + queries: OpenNMSQuerySpec[] + /** Per-series visual overrides (color, type) */ + seriesOverrides: PersesSeriesOverride[] + /** Legend text items rendered below the panel */ + printStatements: PrintStatement[] +} +``` + +- [ ] **Step 3: Verify TypeScript compiles** + +```bash +cd ui && yarn vue-tsc --noEmit 2>&1 | grep "PersesGraphSpec" || echo "OK" +``` + +Expected: no errors + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/types/ +git commit -m "feat(types): add PersesGraphSpec for Perses panel output" +``` + +--- + +## Task 2: Extend RrdGraphConverter to Emit PersesGraphSpec + +**Files:** +- Modify: `ui/src/components/Resources/utils/RrdGraphConverter.class.ts` +- Create: `ui/tests/components/Resources/RrdGraphConverter.perses.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// ui/tests/components/Resources/RrdGraphConverter.perses.test.ts +import { describe, test, expect } from 'vitest' +import RrdGraphConverter from '@/components/Resources/utils/RrdGraphConverter.class' + +// Minimal graph definition as returned by /rest/graphs/ +const sampleGraphDef = { + title: 'Interface Throughput', + verticalLabel: 'Bytes/s', + propertiesValues: [], + columns: ['ifInOctets', 'ifOutOctets'], + command: '--start {rrdstart} --end {rrdend} ' + + 'DEF:a={rrd1}:ifInOctets:AVERAGE ' + + 'DEF:b={rrd2}:ifOutOctets:AVERAGE ' + + 'LINE1:a#0000ff:"In" ' + + 'LINE1:b#ff0000:"Out"' +} + +const resourceId = 'node[1].interfaceSnmp[eth0-000000000000]' + +describe('RrdGraphConverter.toPersesGraphSpec()', () => { + test('produces one OpenNMSQuery per DEF', () => { + const converter = new RrdGraphConverter({ graphDef: sampleGraphDef, resourceId }) + const spec = converter.toPersesGraphSpec() + + expect(spec.queries).toHaveLength(2) + expect(spec.queries[0].attribute).toBe('ifInOctets') + expect(spec.queries[0].resourceId).toBe(resourceId) + expect(spec.queries[0].aggregation).toBe('AVERAGE') + expect(spec.queries[1].attribute).toBe('ifOutOctets') + }) + + test('maps LINE series to seriesOverrides with type=line and color', () => { + const converter = new RrdGraphConverter({ graphDef: sampleGraphDef, resourceId }) + const spec = converter.toPersesGraphSpec() + + expect(spec.seriesOverrides).toHaveLength(2) + expect(spec.seriesOverrides[0]).toMatchObject({ name: 'In', color: '#0000ff', type: 'line' }) + expect(spec.seriesOverrides[1]).toMatchObject({ name: 'Out', color: '#ff0000', type: 'line' }) + }) + + test('sets title and yAxisLabel from graph definition', () => { + const converter = new RrdGraphConverter({ graphDef: sampleGraphDef, resourceId }) + const spec = converter.toPersesGraphSpec() + + expect(spec.title).toBe('Interface Throughput') + expect(spec.yAxisLabel).toBe('Bytes/s') + }) + + test('marks CDEF metrics as expression queries', () => { + const cdefGraphDef = { + ...sampleGraphDef, + columns: ['ifInOctets'], + command: '--start {rrdstart} --end {rrdend} ' + + 'DEF:a={rrd1}:ifInOctets:AVERAGE ' + + 'CDEF:bps=a,8,* ' + + 'LINE1:bps#0000ff:"In bps"' + } + const converter = new RrdGraphConverter({ graphDef: cdefGraphDef, resourceId }) + const spec = converter.toPersesGraphSpec() + + const cdefQuery = spec.queries.find(q => q.expression) + expect(cdefQuery).toBeDefined() + expect(cdefQuery?.expression).toContain('a') + }) +}) +``` + +- [ ] **Step 2: Run failing test** + +```bash +cd ui && yarn test tests/components/Resources/RrdGraphConverter.perses.test.ts +``` + +Expected: FAIL — `toPersesGraphSpec` method does not exist + +- [ ] **Step 3: Add `toPersesGraphSpec()` to RrdGraphConverter** + +In `ui/src/components/Resources/utils/RrdGraphConverter.class.ts`, add the following import at the top and method to the class: + +Add import: +```typescript +import type { PersesGraphSpec, PersesSeriesOverride } from '@/types' +import type { OpenNMSQuerySpec } from '@/datasource/opennms' +``` + +Add method to the `RrdGraphConverter` class (after the constructor): + +```typescript + /** + * Produces a PersesGraphSpec suitable for rendering with . + * This is the Perses equivalent of `this.model` (the Chart.js output). + */ + toPersesGraphSpec(): PersesGraphSpec { + // Build one OpenNMSQuery per metric in this.model.metrics + const queries: OpenNMSQuerySpec[] = this.model.metrics.map(metric => { + if (metric.expression) { + return { + resourceId: this.resourceId as string, + attribute: metric.name as string, + aggregation: 'AVERAGE', + label: metric.name as string, + expression: metric.expression as string, + transient: metric.transient as boolean + } satisfies OpenNMSQuerySpec + } + return { + resourceId: this.resourceId as string, + attribute: metric.attribute as string, + aggregation: (metric.aggregation as OpenNMSQuerySpec['aggregation']) ?? 'AVERAGE', + label: metric.name as string, + transient: metric.transient as boolean + } satisfies OpenNMSQuerySpec + }) + + // Map series to per-series visual overrides + const seriesOverrides: PersesSeriesOverride[] = this.model.series + .filter(s => s.name && s.type !== 'hidden') + .map(s => ({ + name: s.name as string, + color: s.color as string | undefined, + type: (s.type === 'stack' ? 'stack' : s.type === 'area' ? 'area' : 'line') as PersesSeriesOverride['type'] + })) + + return { + title: this.model.title as string, + yAxisLabel: this.model.verticalLabel as string, + queries, + seriesOverrides, + printStatements: this.model.printStatements + } + } +``` + +- [ ] **Step 4: Run passing test** + +```bash +cd ui && yarn test tests/components/Resources/RrdGraphConverter.perses.test.ts +``` + +Expected: PASS + +- [ ] **Step 5: Run all frontend tests to catch regressions** + +```bash +cd ui && yarn test +``` + +Expected: all pass + +- [ ] **Step 6: Commit** + +```bash +git add ui/src/components/Resources/utils/RrdGraphConverter.class.ts \ + ui/tests/components/Resources/RrdGraphConverter.perses.test.ts +git commit -m "feat(converter): add toPersesGraphSpec() to RrdGraphConverter" +``` + +--- + +## Task 3: Refactor Graph.vue to Use PersesPanel + +**Files:** +- Modify: `ui/src/components/Resources/Graph.vue` + +- [ ] **Step 1: Read the current Graph.vue** + +Read `ui/src/components/Resources/Graph.vue` (already done in prior context — 337 lines). The file uses ``, Chart.js, `HtmlLegendPlugin`, and `LegendFormatter`. + +- [ ] **Step 2: Replace Graph.vue** + +Replace the entire file with: + +```vue + + + + + + + + +``` + +- [ ] **Step 3: Verify TypeScript compiles** + +```bash +cd ui && yarn vue-tsc --noEmit 2>&1 | grep -E "Graph\.vue|error" | head -20 +``` + +Expected: no errors for `Graph.vue` + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/components/Resources/Graph.vue +git commit -m "feat(graph): replace Chart.js with PersesPanel in Graph.vue" +``` + +--- + +## Task 4: Delete Removed Files + +**Files:** +- Delete: `ui/src/components/Resources/plugins/HtmlLegendPlugin.ts` +- Delete: `ui/src/components/Resources/utils/LegendFormatter.ts` + +- [ ] **Step 1: Verify nothing else imports these files** + +```bash +grep -rn "HtmlLegendPlugin\|LegendFormatter" ui/src/ --include="*.ts" --include="*.vue" +``` + +Expected: no results (Graph.vue was the only consumer) + +- [ ] **Step 2: Delete the files** + +```bash +rm ui/src/components/Resources/plugins/HtmlLegendPlugin.ts +rm ui/src/components/Resources/utils/LegendFormatter.ts +``` + +- [ ] **Step 3: Verify TypeScript still compiles** + +```bash +cd ui && yarn vue-tsc --noEmit 2>&1 | head -20 +``` + +Expected: no errors + +- [ ] **Step 4: Commit** + +```bash +git add -A ui/src/components/Resources/plugins/ \ + ui/src/components/Resources/utils/ +git commit -m "chore(cleanup): remove HtmlLegendPlugin and LegendFormatter (replaced by Perses)" +``` + +--- + +## Task 5: Remove Chart.js Dependencies + +**Files:** +- Modify: `ui/package.json` + +- [ ] **Step 1: Verify nothing else uses chart.js** + +```bash +grep -rn "chart\.js\|chartjs\|from 'chart" ui/src/ --include="*.ts" --include="*.vue" --include="*.tsx" +``` + +Expected: no results + +- [ ] **Step 2: Remove from package.json** + +In `ui/package.json` dependencies, remove: +``` +"chart.js": "^3.9.1", +"chartjs-plugin-zoom": "^2.0.1", +``` + +- [ ] **Step 3: Install to update lockfile** + +```bash +cd ui && yarn install +``` + +Expected: packages removed from lockfile + +- [ ] **Step 4: Full build to confirm no broken imports** + +```bash +cd ui && yarn build 2>&1 | tail -5 +``` + +Expected: build completes with no errors + +- [ ] **Step 5: Run all tests** + +```bash +cd ui && yarn test +``` + +Expected: all pass + +- [ ] **Step 6: Commit** + +```bash +git add ui/package.json ui/yarn.lock +git commit -m "chore(deps): remove chart.js and chartjs-plugin-zoom" +``` + +--- + +## Phase 2 Complete + +At this point: +- `RrdGraphConverter.toPersesGraphSpec()` converts prefab graph definitions to Perses panel specs +- `Graph.vue` renders all Resource Graphs via Perses `TimeSeriesChart` panels +- `HtmlLegendPlugin`, `LegendFormatter`, `chart.js`, `chartjs-plugin-zoom` are gone +- The Data tab still works (raw measurements still fetched for `GraphDataTable`) + +Proceed to `2026-04-05-perses-phase3-dashboards.md`. diff --git a/docs/superpowers/plans/2026-04-05-perses-phase3-dashboards.md b/docs/superpowers/plans/2026-04-05-perses-phase3-dashboards.md new file mode 100644 index 000000000000..09372796056b --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-perses-phase3-dashboards.md @@ -0,0 +1,993 @@ +# Perses Phase 3 — Dashboards 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:** Build the Dashboards section of the Vue SPA (list, view, edit) backed by `/rest/dashboards`, add server-side redirects from legacy KSC and graph URLs to the new Vue routes, and enable `org.opennms.web.graphs.engine=perses` in the container environment. + +**Architecture:** Two new Vue pages (`DashboardList`, `DashboardViewer`) mount `PersesCanvas` for full Grafana-like editing. New Vue router routes at `/dashboards/*`. Four existing Spring MVC controllers are modified to redirect to the Vue SPA when `org.opennms.web.graphs.engine=perses`. The container overlay `opennms.properties` is updated to activate the Perses engine. + +**Prerequisites:** Phase 1 complete (PersesCanvas, /rest/dashboards API). + +**Tech Stack:** Vue 3, TypeScript, Vue Router 4, Spring MVC (Java), Vitest + +--- + +## File Map + +**New frontend files:** +- `ui/src/components/Dashboards/DashboardList.vue` +- `ui/src/components/Dashboards/DashboardViewer.vue` +- `ui/src/services/dashboardService.ts` +- `ui/tests/services/dashboardService.test.ts` + +**Modified frontend files:** +- `ui/src/router/index.ts` — add `/dashboards/*` routes + +**Modified Java files:** +- `opennms-webapp/src/main/java/org/opennms/web/controller/ksc/IndexController.java` +- `opennms-webapp/src/main/java/org/opennms/web/controller/ksc/CustomReportController.java` +- `opennms-webapp/src/main/java/org/opennms/web/controller/ksc/CustomViewController.java` +- `opennms-webapp/src/main/java/org/opennms/web/controller/ksc/CustomGraphEditDetailsController.java` +- `opennms-webapp/src/main/java/org/opennms/web/controller/GraphResultsController.java` +- `opennms-webapp/src/main/webapp/WEB-INF/jsp/graph/forecast.jsp` — add redirect meta tag + +**Container env:** +- Container overlay `opennms.properties` — set `org.opennms.web.graphs.engine=perses` + +--- + +## Task 1: Dashboard REST Service Client + +**Files:** +- Create: `ui/src/services/dashboardService.ts` +- Create: `ui/tests/services/dashboardService.test.ts` + +- [ ] **Step 1: Write the failing test** + +```typescript +// ui/tests/services/dashboardService.test.ts +import { describe, test, expect, vi, beforeEach } from 'vitest' +import { + listDashboards, + getDashboard, + createDashboard, + updateDashboard, + deleteDashboard +} from '@/services/dashboardService' + +const mockSummary = { id: 'abc-123', name: 'Test', description: '', createdBy: 'admin', createdAt: '2026-01-01', updatedAt: '2026-01-01' } +const mockFull = { ...mockSummary, spec: '{"panels":[]}' } + +beforeEach(() => { + vi.stubGlobal('fetch', vi.fn()) +}) + +describe('dashboardService', () => { + test('listDashboards GETs /rest/dashboards', async () => { + vi.mocked(fetch).mockResolvedValue({ ok: true, json: () => Promise.resolve([mockSummary]) } as any) + const result = await listDashboards() + expect(fetch).toHaveBeenCalledWith('/rest/dashboards', expect.objectContaining({ method: 'GET' })) + expect(result[0].id).toBe('abc-123') + }) + + test('getDashboard GETs /rest/dashboards/:id', async () => { + vi.mocked(fetch).mockResolvedValue({ ok: true, json: () => Promise.resolve(mockFull) } as any) + const result = await getDashboard('abc-123') + expect(fetch).toHaveBeenCalledWith('/rest/dashboards/abc-123', expect.anything()) + expect(result.spec).toBe('{"panels":[]}') + }) + + test('createDashboard POSTs and returns created dashboard', async () => { + vi.mocked(fetch).mockResolvedValue({ ok: true, status: 201, json: () => Promise.resolve(mockFull) } as any) + const result = await createDashboard({ name: 'Test', spec: '{}' }) + expect(fetch).toHaveBeenCalledWith('/rest/dashboards', expect.objectContaining({ method: 'POST' })) + expect(result.id).toBe('abc-123') + }) + + test('updateDashboard PUTs to /rest/dashboards/:id', async () => { + vi.mocked(fetch).mockResolvedValue({ ok: true, json: () => Promise.resolve(mockFull) } as any) + await updateDashboard('abc-123', { name: 'Updated', spec: '{}' }) + expect(fetch).toHaveBeenCalledWith('/rest/dashboards/abc-123', expect.objectContaining({ method: 'PUT' })) + }) + + test('deleteDashboard DELETEs /rest/dashboards/:id', async () => { + vi.mocked(fetch).mockResolvedValue({ ok: true, status: 204, text: () => Promise.resolve('') } as any) + await deleteDashboard('abc-123') + expect(fetch).toHaveBeenCalledWith('/rest/dashboards/abc-123', expect.objectContaining({ method: 'DELETE' })) + }) +}) +``` + +- [ ] **Step 2: Run failing test** + +```bash +cd ui && yarn test tests/services/dashboardService.test.ts +``` + +Expected: FAIL + +- [ ] **Step 3: Implement the service** + +```typescript +// ui/src/services/dashboardService.ts + +export interface DashboardSummary { + id: string + name: string + description?: string + createdBy?: string + createdAt: string + updatedAt: string +} + +export interface DashboardFull extends DashboardSummary { + spec: string // Perses DashboardSpec JSON string +} + +export interface DashboardCreate { + name: string + description?: string + spec: string +} + +const BASE = '/rest/dashboards' +const JSON_HEADERS = { 'Content-Type': 'application/json', 'Accept': 'application/json' } + +export async function listDashboards(): Promise { + const res = await fetch(BASE, { method: 'GET', headers: { 'Accept': 'application/json' } }) + if (!res.ok) throw new Error(`Failed to list dashboards: ${res.status}`) + return res.json() +} + +export async function getDashboard(id: string): Promise { + const res = await fetch(`${BASE}/${id}`, { method: 'GET', headers: { 'Accept': 'application/json' } }) + if (!res.ok) throw new Error(`Failed to get dashboard ${id}: ${res.status}`) + return res.json() +} + +export async function createDashboard(payload: DashboardCreate): Promise { + const res = await fetch(BASE, { + method: 'POST', + headers: JSON_HEADERS, + body: JSON.stringify(payload) + }) + if (!res.ok) throw new Error(`Failed to create dashboard: ${res.status}`) + return res.json() +} + +export async function updateDashboard(id: string, payload: DashboardCreate): Promise { + const res = await fetch(`${BASE}/${id}`, { + method: 'PUT', + headers: JSON_HEADERS, + body: JSON.stringify(payload) + }) + if (!res.ok) throw new Error(`Failed to update dashboard ${id}: ${res.status}`) + return res.json() +} + +export async function deleteDashboard(id: string): Promise { + const res = await fetch(`${BASE}/${id}`, { method: 'DELETE' }) + if (!res.ok) throw new Error(`Failed to delete dashboard ${id}: ${res.status}`) +} +``` + +- [ ] **Step 4: Run passing test** + +```bash +cd ui && yarn test tests/services/dashboardService.test.ts +``` + +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/services/dashboardService.ts \ + ui/tests/services/dashboardService.test.ts +git commit -m "feat(service): add dashboardService REST client" +``` + +--- + +## Task 2: DashboardList Vue Page + +**Files:** +- Create: `ui/src/components/Dashboards/DashboardList.vue` + +- [ ] **Step 1: Create the component** + +```vue + + + + + + +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +```bash +cd ui && yarn vue-tsc --noEmit 2>&1 | grep "DashboardList" || echo "OK" +``` + +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/components/Dashboards/DashboardList.vue +git commit -m "feat(dashboards): add DashboardList page" +``` + +--- + +## Task 3: DashboardViewer Vue Page + +**Files:** +- Create: `ui/src/components/Dashboards/DashboardViewer.vue` + +Handles view mode, edit mode, and new dashboard creation. Mounts `PersesCanvas`. + +- [ ] **Step 1: Create the component** + +```vue + + + + + + +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +```bash +cd ui && yarn vue-tsc --noEmit 2>&1 | grep "DashboardViewer" || echo "OK" +``` + +Expected: no errors + +- [ ] **Step 3: Commit** + +```bash +git add ui/src/components/Dashboards/DashboardViewer.vue +git commit -m "feat(dashboards): add DashboardViewer page (view/edit/new)" +``` + +--- + +## Task 4: Register Vue Router Routes + +**Files:** +- Modify: `ui/src/router/index.ts` + +- [ ] **Step 1: Read the current router file** + +```bash +head -60 ui/src/router/index.ts +``` + +Note the import pattern used for other lazy-loaded components. + +- [ ] **Step 2: Add dashboard routes** + +In `ui/src/router/index.ts`, add the following imports near the top with other lazy component imports: + +```typescript +const DashboardList = () => import('@/components/Dashboards/DashboardList.vue') +const DashboardViewer = () => import('@/components/Dashboards/DashboardViewer.vue') +``` + +In the routes array, add: + +```typescript + { + path: '/dashboards', + component: DashboardList, + name: 'Dashboards' + }, + { + path: '/dashboards/new', + component: DashboardViewer, + name: 'NewDashboard' + }, + { + path: '/dashboards/:id', + component: DashboardViewer, + name: 'DashboardView' + }, + { + path: '/dashboards/:id/edit', + component: DashboardViewer, + name: 'DashboardEdit' + }, +``` + +- [ ] **Step 3: Verify TypeScript compiles and build succeeds** + +```bash +cd ui && yarn build 2>&1 | tail -5 +``` + +Expected: build succeeds + +- [ ] **Step 4: Commit** + +```bash +git add ui/src/router/index.ts +git commit -m "feat(router): add /dashboards/* routes" +``` + +--- + +## Task 5: Add Dashboards Nav Link + +**Files:** +- Modify: wherever the Vue SPA's side navigation links are defined + +- [ ] **Step 1: Find the nav menu definition** + +```bash +grep -rn "Resource Graphs\|resource-graphs\|Alarms\|NodeDetail" ui/src/ --include="*.vue" --include="*.ts" | grep -i "nav\|menu\|link\|route" | head -10 +``` + +Note the file that defines the navigation items. + +- [ ] **Step 2: Add the Dashboards nav entry** + +In the nav definition file, add a Dashboards entry following the same pattern as existing entries. Example (adjust to match actual nav structure): + +```typescript +{ + label: 'Dashboards', + icon: 'dashboard', // or whichever Feather icon is appropriate + to: '/dashboards' +} +``` + +- [ ] **Step 3: Verify TypeScript compiles** + +```bash +cd ui && yarn vue-tsc --noEmit 2>&1 | head -10 +``` + +Expected: no errors + +- [ ] **Step 4: Commit** + +```bash +git add -p ui/src/ +git commit -m "feat(nav): add Dashboards link to Vue SPA navigation" +``` + +--- + +## Task 6: KSC Controller Redirects + +**Files:** +- Modify: `opennms-webapp/src/main/java/org/opennms/web/controller/ksc/IndexController.java` +- Modify: `opennms-webapp/src/main/java/org/opennms/web/controller/ksc/CustomReportController.java` +- Modify: `opennms-webapp/src/main/java/org/opennms/web/controller/ksc/CustomViewController.java` +- Modify: `opennms-webapp/src/main/java/org/opennms/web/controller/ksc/CustomGraphEditDetailsController.java` + +Follow the same pattern as `AlarmDetailController.java:112-119` — check `System.getProperty`, call `response.sendRedirect`, return null. + +The system property is `org.opennms.web.graphs.engine`. Redirect when it equals `"perses"`. + +- [ ] **Step 1: Read each controller to find the handler method** + +```bash +grep -n "handleRequestInternal\|handleRequest\|@RequestMapping\|ModelAndView" \ + opennms-webapp/src/main/java/org/opennms/web/controller/ksc/IndexController.java | head -10 +grep -n "handleRequestInternal\|ModelAndView" \ + opennms-webapp/src/main/java/org/opennms/web/controller/ksc/CustomReportController.java | head -10 +``` + +- [ ] **Step 2: Add redirect to IndexController** + +At the top of `handleRequestInternal` (or equivalent handler method) in `IndexController.java`, add: + +```java +// Redirect to Vue SPA Dashboards when Perses engine is active +if ("perses".equals(System.getProperty("org.opennms.web.graphs.engine", "backshift"))) { + response.sendRedirect(request.getContextPath() + "/ui/index.html#/dashboards"); + return null; +} +``` + +- [ ] **Step 3: Add redirect to CustomReportController** + +At the top of its handler method in `CustomReportController.java`, add: + +```java +if ("perses".equals(System.getProperty("org.opennms.web.graphs.engine", "backshift"))) { + response.sendRedirect(request.getContextPath() + "/ui/index.html#/dashboards"); + return null; +} +``` + +- [ ] **Step 4: Add redirect to CustomViewController** + +At the top of its handler method in `CustomViewController.java`, add: + +```java +if ("perses".equals(System.getProperty("org.opennms.web.graphs.engine", "backshift"))) { + response.sendRedirect(request.getContextPath() + "/ui/index.html#/dashboards"); + return null; +} +``` + +- [ ] **Step 5: Add redirect to CustomGraphEditDetailsController** + +At the top of its handler method in `CustomGraphEditDetailsController.java`, add: + +```java +if ("perses".equals(System.getProperty("org.opennms.web.graphs.engine", "backshift"))) { + response.sendRedirect(request.getContextPath() + "/ui/index.html#/dashboards/new"); + return null; +} +``` + +- [ ] **Step 6: Compile opennms-webapp** + +```bash +./compile.pl -DskipTests --projects :opennms-webapp install +``` + +Expected: `BUILD SUCCESS` + +- [ ] **Step 7: Commit** + +```bash +git add opennms-webapp/src/main/java/org/opennms/web/controller/ksc/ +git commit -m "feat(ksc): redirect KSC controllers to Vue Dashboards when engine=perses" +``` + +--- + +## Task 7: Graph Results Controller Redirect + +**Files:** +- Modify: `opennms-webapp/src/main/java/org/opennms/web/controller/GraphResultsController.java` + +- [ ] **Step 1: Read the current handler** + +```bash +grep -n "handleRequestInternal\|resourceId\|reports\|sendRedirect" \ + opennms-webapp/src/main/java/org/opennms/web/controller/GraphResultsController.java | head -15 +``` + +- [ ] **Step 2: Add redirect at the top of `handleRequestInternal`** + +The existing handler reads `resourceId` and `reports` from request params. Preserve those for the redirect URL: + +```java +@Override +protected ModelAndView handleRequestInternal(HttpServletRequest request, HttpServletResponse response) throws Exception { + // Redirect to Vue SPA Resource Graphs when Perses engine is active + if ("perses".equals(System.getProperty("org.opennms.web.graphs.engine", "backshift"))) { + final String resourceId = request.getParameter("resourceId"); + if (resourceId != null && !resourceId.trim().isEmpty()) { + // Encode the resourceId for use in a hash fragment + final String encoded = java.net.URLEncoder.encode(resourceId.trim(), StandardCharsets.UTF_8.name()); + response.sendRedirect(request.getContextPath() + + "/ui/index.html#/resource-graphs/graphs/" + encoded); + } else { + response.sendRedirect(request.getContextPath() + "/ui/index.html#/resource-graphs"); + } + return null; + } + // ... existing code continues unchanged below this block +``` + +Note: add `import java.nio.charset.StandardCharsets;` if not already present. + +- [ ] **Step 3: Compile** + +```bash +./compile.pl -DskipTests --projects :opennms-webapp install +``` + +Expected: `BUILD SUCCESS` + +- [ ] **Step 4: Commit** + +```bash +git add opennms-webapp/src/main/java/org/opennms/web/controller/GraphResultsController.java +git commit -m "feat(graph): redirect graph/results.htm to Vue SPA when engine=perses" +``` + +--- + +## Task 8: Retire forecast.jsp + +**Files:** +- Modify: `opennms-webapp/src/main/webapp/graph/forecast.jsp` + +The forecast page is retired — no Vue redirect exists yet (Perses forecast panel is future work). Add a redirect to the Dashboards list so old bookmarks don't 404. + +- [ ] **Step 1: Read the current forecast.jsp header** + +```bash +head -20 opennms-webapp/src/main/webapp/graph/forecast.jsp +``` + +- [ ] **Step 2: Add a redirect at the very top of forecast.jsp** + +Add before any other content at the top of `forecast.jsp`: + +```jsp +<%-- + forecast.jsp — retired in favor of Perses-based forecast (future feature). + Redirect to Dashboards when Perses engine is active. +--%> +<%@ page import="org.opennms.core.utils.TimeSeries" %> +<% + if ("perses".equals(TimeSeries.getGraphEngine())) { + response.sendRedirect(request.getContextPath() + "/ui/index.html#/dashboards"); + return; + } +%> +``` + +- [ ] **Step 3: Compile** + +```bash +./compile.pl -DskipTests --projects :opennms-webapp install +``` + +Expected: `BUILD SUCCESS` + +- [ ] **Step 4: Commit** + +```bash +git add opennms-webapp/src/main/webapp/graph/forecast.jsp +git commit -m "feat(forecast): redirect to Vue Dashboards when engine=perses" +``` + +--- + +## Task 9: Update TimeSeries.java to Recognize "perses" + +**Files:** +- Modify: `core/lib/src/main/java/org/opennms/core/utils/TimeSeries.java` + +The `getGraphEngine()` method currently returns `"backshift"` for the `"auto"` value. Add `"perses"` as a recognized valid value (currently it passes through as-is, which is fine — but document it clearly). + +- [ ] **Step 1: Read TimeSeries.java** + +Already read in prior context. The key constant is `DEFAULT_GRAPHS_ENGINE_TYPE = "backshift"`. + +- [ ] **Step 2: Add perses constant and update javadoc** + +In `TimeSeries.java`, add after the existing constants: + +```java + public static final String PERSES_GRAPHS_ENGINE_TYPE = "perses"; +``` + +Update the `getGraphEngine()` javadoc: + +```java + /** + * Returns the configured graph engine. + * Valid values: "backshift" (default), "png", "placeholder", "perses". + * Set via system property: org.opennms.web.graphs.engine + */ + public static String getGraphEngine() { +``` + +- [ ] **Step 3: Compile** + +```bash +./compile.pl -DskipTests --projects :opennms-core install +``` + +Expected: `BUILD SUCCESS` + +- [ ] **Step 4: Commit** + +```bash +git add core/lib/src/main/java/org/opennms/core/utils/TimeSeries.java +git commit -m "feat(timeseries): document perses as valid graph engine value" +``` + +--- + +## Task 10: Set engine=perses in Container Environment + +**Files:** +- Modify: container overlay `opennms.properties` (in the container build/overlay directory) + +- [ ] **Step 1: Find the container overlay opennms.properties** + +```bash +find /Users/chance/git/opennms -name "opennms.properties" | grep -v target | grep -v node_modules | head -5 +``` + +Note the path used in the container overlay (check `.topology-lab/` or the existing container overlay scripts from prior work). + +- [ ] **Step 2: Add the Perses engine property** + +In the container overlay `opennms.properties` (the one copied into the container at build time), add: + +```properties +# Enable Perses-based graph/dashboard rendering +org.opennms.web.graphs.engine=perses +``` + +- [ ] **Step 3: Rebuild and verify the container picks up the property** + +Follow the existing container overlay build procedure. After startup, verify: + +```bash +podman exec grep "graphs.engine" /opt/opennms/etc/opennms.properties +``` + +Expected: `org.opennms.web.graphs.engine=perses` + +- [ ] **Step 4: Smoke test — verify KSC redirect works** + +```bash +curl -s -o /dev/null -w "%{http_code} %{redirect_url}" \ + -u admin:notdefault \ + http://localhost:8980/opennms/KSC/index.htm +``` + +Expected: `302 http://localhost:8980/opennms/ui/index.html#/dashboards` + +- [ ] **Step 5: Commit the overlay change** + +```bash +git add +git commit -m "feat(container): set org.opennms.web.graphs.engine=perses in overlay" +``` + +--- + +## Task 11: Run All Tests + +- [ ] **Step 1: Run all frontend tests** + +```bash +cd ui && yarn test +``` + +Expected: all pass + +- [ ] **Step 2: Build the frontend** + +```bash +cd ui && yarn build 2>&1 | tail -5 +``` + +Expected: build success + +- [ ] **Step 3: Compile affected Java modules** + +```bash +./compile.pl -DskipTests --projects :opennms-webapp,:opennms-webapp-rest -am install +``` + +Expected: `BUILD SUCCESS` + +- [ ] **Step 4: Commit if any fixes were needed** + +```bash +git add -p +git commit -m "fix(phase3): address any build issues found during final test run" +``` + +--- + +## Phase 3 Complete + +At this point: +- `/dashboards`, `/dashboards/new`, `/dashboards/:id`, `/dashboards/:id/edit` are live Vue routes +- `DashboardList` shows all saved Perses dashboards with create/delete +- `DashboardViewer` mounts `PersesCanvas` for view, edit, and new dashboard workflows +- `/KSC/*`, `/graph/results.htm`, `/graph/forecast.jsp` redirect to Vue SPA when engine=perses +- Container environment runs with engine=perses + +Proceed to `2026-04-05-perses-phase4-retirement.md`. diff --git a/docs/superpowers/plans/2026-04-05-perses-phase4-retirement.md b/docs/superpowers/plans/2026-04-05-perses-phase4-retirement.md new file mode 100644 index 000000000000..eac06c75261c --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-perses-phase4-retirement.md @@ -0,0 +1,414 @@ +# Perses Phase 4 — Backshift Retirement 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:** Remove the legacy Backshift/Flot graph rendering infrastructure now that it has been fully replaced by Perses. Delete vendor JS bundles, the `onms-graph` and `forecast` webpack apps, remove `bootstrap.jsp`'s backshift asset loading, and change the `TimeSeries` default engine to `"perses"`. + +**Architecture:** Pure cleanup — no new code is written. Every deletion is preceded by a grep to confirm no remaining callers. The `usebackshift` and `renderGraphs` bootstrap.jsp flags are removed. The `DEFAULT_GRAPHS_ENGINE_TYPE` constant changes to `"perses"`. + +**Prerequisites:** Phase 2 and Phase 3 complete and verified in the container environment. + +**Tech Stack:** Java, JSP, webpack (legacy `core/web-assets`) + +--- + +## File Map + +**Deleted:** +- `core/web-assets/src/main/assets/js/apps/onms-graph/index.js` +- `core/web-assets/src/main/assets/js/apps/forecast/index.js` +- `core/web-assets/src/main/assets/js/vendor/backshift-js.js` + +**Modified:** +- `core/web-assets/package.json` — remove `backshift` npm dependency +- `core/web-assets/webpack.config.js` — remove `onms-graph` and `forecast` entry points +- `opennms-webapp/src/main/webapp/includes/bootstrap.jsp` — remove backshift/flot asset loading +- `core/lib/src/main/java/org/opennms/core/utils/TimeSeries.java` — change default engine to `"perses"` + +**Possibly deleted** (verify first): +- `core/web-assets/src/main/assets/js/vendor/flot-js.js` — only if not used by anything other than backshift/NRTG + +--- + +## Task 1: Confirm No Remaining Callers of Backshift + +Before deleting anything, verify all consumers have been replaced. + +- [ ] **Step 1: Check for backshift imports in web-assets** + +```bash +grep -rn "backshift\|Backshift" core/web-assets/src/main/assets/js/ --include="*.js" --include="*.ts" | grep -v "vendor/backshift-js" +``` + +Expected: only `onms-graph/index.js` and `forecast/index.js` import backshift. If any other file imports it, stop and investigate before proceeding. + +- [ ] **Step 2: Check for graph-container divs in JSPs** + +```bash +grep -rn "graph-container\|renderGraphs\|usebackshift" opennms-webapp/src/main/webapp/ --include="*.jsp" | grep -v "#" +``` + +Expected: only `bootstrap.jsp` references these flags. The KSC and graph result JSPs should no longer inject `graph-container` divs when the engine is `perses` (because the controllers redirect before the JSP renders). If any JSP still uses `graph-container` outside of a perses-gate, flag it. + +- [ ] **Step 3: Check for flot.js usage outside backshift** + +```bash +grep -rn "flot-js\|require.*flot\|import.*flot" core/web-assets/src/main/assets/js/ --include="*.js" | grep -v "vendor/backshift-js\|apps/forecast\|apps/onms-graph" +grep -rn "flot-js\|require.*flot" opennms-webapp/src/main/webapp/ --include="*.jsp" +``` + +If `flot-js` is referenced by NRTG (`nrtg.jsp`) or any other page outside the backshift chain, note it — flot.js deletion may need to be deferred. + +- [ ] **Step 4: Document findings** + +If all checks are clean, proceed. If any unexpected caller is found, open a follow-up task before deleting. + +--- + +## Task 2: Delete onms-graph App + +**Files:** +- Delete: `core/web-assets/src/main/assets/js/apps/onms-graph/index.js` + +- [ ] **Step 1: Verify no callers** + +```bash +grep -rn "onms-graph\|apps/onms-graph" core/web-assets/src/ --include="*.js" --include="*.ts" --include="*.json" +``` + +Expected: references only in `webpack.config.js` (entry point) and possibly `bootstrap.jsp` (asset load). Both will be cleaned up in subsequent tasks. + +- [ ] **Step 2: Delete the file** + +```bash +rm core/web-assets/src/main/assets/js/apps/onms-graph/index.js +# Remove the directory if it's now empty: +rmdir core/web-assets/src/main/assets/js/apps/onms-graph/ 2>/dev/null || true +``` + +- [ ] **Step 3: Commit** + +```bash +git add core/web-assets/src/main/assets/js/apps/onms-graph/ +git commit -m "chore(cleanup): remove onms-graph webpack app (replaced by Perses)" +``` + +--- + +## Task 3: Delete forecast App + +**Files:** +- Delete: `core/web-assets/src/main/assets/js/apps/forecast/index.js` + +- [ ] **Step 1: Verify no callers outside forecast.jsp** + +```bash +grep -rn "apps/forecast\|forecast/index" core/web-assets/src/ --include="*.js" --include="*.json" +grep -rn "asset.*forecast\|forecast.*asset" opennms-webapp/src/main/webapp/ --include="*.jsp" +``` + +Expected: only `webpack.config.js` and `forecast.jsp`. The `forecast.jsp` now redirects immediately when engine=perses so the asset is never loaded in the Perses path. + +- [ ] **Step 2: Delete the file** + +```bash +rm core/web-assets/src/main/assets/js/apps/forecast/index.js +rmdir core/web-assets/src/main/assets/js/apps/forecast/ 2>/dev/null || true +``` + +- [ ] **Step 3: Commit** + +```bash +git add core/web-assets/src/main/assets/js/apps/forecast/ +git commit -m "chore(cleanup): remove forecast webpack app (Perses forecast is future work)" +``` + +--- + +## Task 4: Delete backshift-js Vendor Bundle + +**Files:** +- Delete: `core/web-assets/src/main/assets/js/vendor/backshift-js.js` + +- [ ] **Step 1: Final check for any remaining backshift import** + +```bash +grep -rn "backshift-js\|require.*backshift\|vendor/backshift" core/web-assets/src/ --include="*.js" +``` + +Expected: zero results (onms-graph and forecast were the only importers, now deleted) + +- [ ] **Step 2: Delete the vendor bundle** + +```bash +rm core/web-assets/src/main/assets/js/vendor/backshift-js.js +``` + +- [ ] **Step 3: Commit** + +```bash +git add core/web-assets/src/main/assets/js/vendor/backshift-js.js +git commit -m "chore(cleanup): remove backshift-js vendor bundle" +``` + +--- + +## Task 5: Remove backshift npm Dependency + +**Files:** +- Modify: `core/web-assets/package.json` +- Modify: `core/web-assets/webpack.config.js` + +- [ ] **Step 1: Read the current webpack config entries** + +```bash +grep -n "onms-graph\|forecast\|backshift" core/web-assets/webpack.config.js | head -10 +``` + +Note the exact entry point keys to remove. + +- [ ] **Step 2: Remove entry points from webpack.config.js** + +Remove the `'onms-graph'` and `'forecast'` entries from the webpack `entry` object in `core/web-assets/webpack.config.js`. + +- [ ] **Step 3: Remove backshift from package.json** + +In `core/web-assets/package.json`, remove the `backshift` dependency entry (it's pinned to a GitHub tarball). Run: + +```bash +cd core/web-assets && grep "backshift" package.json +``` + +Remove that line from `package.json`. + +- [ ] **Step 4: Install to clean up lockfile** + +```bash +cd core/web-assets && npm install +``` + +Expected: backshift removed from `package-lock.json` + +- [ ] **Step 5: Build the web-assets module to verify** + +```bash +./compile.pl -DskipTests --projects :org.opennms.features.distributed:core-web-assets install +``` + +If the artifact ID differs, find it with: +```bash +tools/development/pom-artifact.sh core/web-assets/pom.xml +``` + +Expected: `BUILD SUCCESS` + +- [ ] **Step 6: Commit** + +```bash +git add core/web-assets/package.json core/web-assets/package-lock.json core/web-assets/webpack.config.js +git commit -m "chore(deps): remove backshift npm dependency and webpack entry points" +``` + +--- + +## Task 6: Clean Up bootstrap.jsp + +**Files:** +- Modify: `opennms-webapp/src/main/webapp/includes/bootstrap.jsp` + +- [ ] **Step 1: Read the relevant section** + +Read lines 172-197 of `bootstrap.jsp` (already read in prior context). The section to remove is: + +```jsp + + + + + + + + + + <%-- ... --%> + + + + +``` + +- [ ] **Step 2: Remove both c:if blocks** + +Remove the entire `renderGraphs` block (lines 172-184) and the entire `usebackshift` block (lines 186-197) from `bootstrap.jsp`. + +- [ ] **Step 3: Verify bootstrap.jsp still compiles** + +```bash +./compile.pl -DskipTests --projects :opennms-webapp install +``` + +Expected: `BUILD SUCCESS` + +- [ ] **Step 4: Commit** + +```bash +git add opennms-webapp/src/main/webapp/includes/bootstrap.jsp +git commit -m "chore(cleanup): remove renderGraphs and usebackshift asset loading from bootstrap.jsp" +``` + +--- + +## Task 7: Optionally Delete flot-js + +- [ ] **Step 1: Check if flot-js is still used** + +```bash +grep -rn "flot-js\|require.*flot\|vendor/flot" \ + core/web-assets/src/main/assets/js/ \ + opennms-webapp/src/main/webapp/ \ + --include="*.js" --include="*.jsp" --include="*.ts" +``` + +- If **zero results**: delete `core/web-assets/src/main/assets/js/vendor/flot-js.js` and commit. +- If **results found** (e.g., `nrtg.jsp`): leave flot-js in place and open a follow-up task to handle the remaining consumer. + +```bash +# Only run if zero results above: +rm core/web-assets/src/main/assets/js/vendor/flot-js.js +git add core/web-assets/src/main/assets/js/vendor/flot-js.js +git commit -m "chore(cleanup): remove flot-js vendor bundle (no remaining consumers)" +``` + +--- + +## Task 8: Change Default Graph Engine to "perses" + +**Files:** +- Modify: `core/lib/src/main/java/org/opennms/core/utils/TimeSeries.java` + +- [ ] **Step 1: Change the constant** + +In `TimeSeries.java`, change: + +```java +public static final String DEFAULT_GRAPHS_ENGINE_TYPE = "backshift"; +``` + +to: + +```java +public static final String DEFAULT_GRAPHS_ENGINE_TYPE = "perses"; +``` + +- [ ] **Step 2: Verify `getGraphEngine()` still works** + +The method already handles `"auto"` specially (returns `DEFAULT_GRAPHS_ENGINE_TYPE`). Since the default is now `"perses"`, both `"auto"` and an unset property will return `"perses"`. + +- [ ] **Step 3: Compile** + +```bash +./compile.pl -DskipTests --projects :opennms-core install +``` + +Expected: `BUILD SUCCESS` + +- [ ] **Step 4: Run TimeSeries-related tests if any exist** + +```bash +grep -rn "TimeSeries\|getGraphEngine" opennms-*/src/test/ --include="*.java" -l | head -5 +``` + +Run any found test files to confirm no regressions. + +- [ ] **Step 5: Commit** + +```bash +git add core/lib/src/main/java/org/opennms/core/utils/TimeSeries.java +git commit -m "feat(timeseries): change default graph engine from backshift to perses" +``` + +--- + +## Task 9: Final Build Verification + +- [ ] **Step 1: Full build of affected modules** + +```bash +./compile.pl -DskipTests --projects :opennms-webapp,:opennms-webapp-rest,:opennms-core -am install +``` + +Expected: `BUILD SUCCESS` + +- [ ] **Step 2: Full frontend build** + +```bash +cd ui && yarn build 2>&1 | tail -10 +``` + +Expected: success, no warnings about missing modules + +- [ ] **Step 3: Run all frontend tests** + +```bash +cd ui && yarn test +``` + +Expected: all pass + +- [ ] **Step 4: Container smoke test** + +Rebuild the overlay container and verify: + +```bash +# After container starts (health check passes): + +# 1. Backshift assets are NOT served +curl -s -o /dev/null -w "%{http_code}" \ + http://localhost:8980/opennms/assets/backshift-js.js +# Expected: 404 + +# 2. KSC redirect still works +curl -s -o /dev/null -w "%{http_code} %{redirect_url}" \ + -u admin:notdefault \ + http://localhost:8980/opennms/KSC/index.htm +# Expected: 302 .../ui/index.html#/dashboards + +# 3. Graph results redirect +curl -s -o /dev/null -w "%{http_code} %{redirect_url}" \ + -u admin:notdefault \ + "http://localhost:8980/opennms/graph/results.htm?resourceId=node%5B1%5D" +# Expected: 302 .../ui/index.html#/resource-graphs/graphs/node%5B1%5D + +# 4. Dashboards API is live +curl -s -u admin:notdefault \ + http://localhost:8980/opennms/rest/dashboards +# Expected: [] +``` + +- [ ] **Step 5: Commit any final fixes** + +```bash +git add -p +git commit -m "fix(phase4): final build and smoke test fixes" +``` + +--- + +## Phase 4 Complete — Backshift Infrastructure Retired + +At this point: +- `backshift-js.js`, `onms-graph/index.js`, `forecast/index.js`, `flot-js.js` (if clean) are gone +- `bootstrap.jsp` no longer loads backshift or flot assets +- `TimeSeries.DEFAULT_GRAPHS_ENGINE_TYPE` is `"perses"` +- The default engine for all new OpenNMS installations is Perses +- The legacy `"backshift"` value still works as an explicit opt-out (for operators who need the legacy rendering) + +**Total changes across all 4 phases:** +- New: Liquibase migration, Java entity/DAO/REST, OpenNMS Perses datasource plugin, React mount infrastructure, `DashboardList`, `DashboardViewer`, dashboard REST service +- Updated: `RrdGraphConverter` (Perses output), `Graph.vue` (PersesPanel), `TimeSeries.java`, `bootstrap.jsp`, 5 Spring MVC controllers, Vue router +- Deleted: `backshift-js.js`, `onms-graph/index.js`, `forecast/index.js`, `HtmlLegendPlugin.ts`, `LegendFormatter.ts`, `chart.js`, `chartjs-plugin-zoom` diff --git a/docs/superpowers/plans/2026-04-05-test-fixture-seed-script.md b/docs/superpowers/plans/2026-04-05-test-fixture-seed-script.md new file mode 100644 index 000000000000..4ac78a82e5e1 --- /dev/null +++ b/docs/superpowers/plans/2026-04-05-test-fixture-seed-script.md @@ -0,0 +1,388 @@ +# Test Fixture Seed Script 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:** Create `ui/seed-test-fixtures.sh` — an idempotent shell script that inserts a stable `UI-Test-Node` with one active and two resolved ICMP outages into the `test-opennms` container's PostgreSQL database. + +**Architecture:** Pure SQL via `psql` against the exposed port 5432. All inserts are wrapped in a single `DO $$ … $$ LANGUAGE plpgsql` block with `IF NOT EXISTS` guards. A `--reset` flag deletes all `UI-Test` rows before re-inserting. No provisioning pipeline, no polling, no fake events. + +**Tech Stack:** Bash, PostgreSQL 14 (psql client on macOS host), OpenNMS 35.x schema. + +--- + +## Schema Reference (confirmed from live container) + +| Table | PK column | Sequence | Notes | +|---|---|---|---| +| `node` | `nodeid` | `nodenxtid` | `location NOT NULL`, no `createtime` column | +| `ipinterface` | `id` | `opennmsnxtid` | `ipaddr TEXT NOT NULL`, `nodeid NOT NULL` | +| `ifservices` | `id` | `opennmsnxtid` | `serviceid=3` is ICMP | +| `outages` | `outageid` | `outagenxtid` | `ifserviceid NOT NULL`, `svclosteventid` nullable | + +DB credentials (host-side): `PGPASSWORD=opennms psql -h localhost -p 5432 -U opennms -d opennms` + +> **Note:** The spec listed PGUSER=postgres/PGPASSWORD=postgres — these are wrong. The correct credentials are PGUSER=opennms / PGPASSWORD=opennms. + +--- + +## File Structure + +| File | Action | Purpose | +|---|---|---| +| `ui/seed-test-fixtures.sh` | **Create** | Idempotent SQL seed script | + +No other files are created or modified. + +--- + +### Task 1: Write and verify the seed script + +**Files:** +- Create: `ui/seed-test-fixtures.sh` + +- [ ] **Step 1: Create the script** + +```bash +#!/usr/bin/env bash +# seed-test-fixtures.sh +# Inserts a stable UI-Test-Node with 1 active + 2 resolved ICMP outages +# into the running test-opennms container's PostgreSQL database. +# +# Usage: +# ./seed-test-fixtures.sh # no-op if already seeded +# ./seed-test-fixtures.sh --reset # drop UI-Test rows and re-insert +# +# DB connection env vars (all have defaults): +# PGHOST, PGPORT, PGUSER, PGPASSWORD, PGDATABASE + +set -euo pipefail + +PGHOST="${PGHOST:-localhost}" +PGPORT="${PGPORT:-5432}" +PGUSER="${PGUSER:-opennms}" +PGPASSWORD="${PGPASSWORD:-opennms}" +PGDATABASE="${PGDATABASE:-opennms}" +export PGPASSWORD + +RESET=false +if [[ "${1:-}" == "--reset" ]]; then + RESET=true +fi + +# Verify psql is available +if ! command -v psql &>/dev/null; then + echo "ERROR: psql not found. Install postgresql client (brew install libpq)." >&2 + exit 1 +fi + +# Verify DB is reachable +if ! psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" \ + -c "SELECT 1;" &>/dev/null; then + echo "ERROR: Cannot connect to PostgreSQL at $PGHOST:$PGPORT as $PGUSER." >&2 + exit 1 +fi + +echo "Connected to $PGDATABASE at $PGHOST:$PGPORT" + +psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" <<'SQL' +DO $$ +DECLARE + v_nodeid INTEGER; + v_ifid INTEGER; + v_svcid INTEGER; +BEGIN + + -- ── Reset (optional) ────────────────────────────────────────────────────── + -- Cascades: node → ipinterface → ifservices → outages + IF current_setting('app.reset', true) = 'true' THEN + DELETE FROM node WHERE foreignsource = 'UI-Test'; + RAISE NOTICE 'UI-Test rows deleted (reset).'; + END IF; + + -- ── Node ────────────────────────────────────────────────────────────────── + SELECT nodeid INTO v_nodeid + FROM node + WHERE foreignsource = 'UI-Test' AND foreignid = 'test-node-01'; + + IF v_nodeid IS NULL THEN + v_nodeid := nextval('nodenxtid'); + INSERT INTO node (nodeid, nodelabel, foreignsource, foreignid, nodetype, location) + VALUES (v_nodeid, 'UI-Test-Node', 'UI-Test', 'test-node-01', 'A', 'Default'); + RAISE NOTICE 'Inserted node id=% (UI-Test-Node)', v_nodeid; + ELSE + RAISE NOTICE 'Node already exists id=% — skipping inserts.', v_nodeid; + RETURN; + END IF; + + -- ── IP Interface ────────────────────────────────────────────────────────── + v_ifid := nextval('opennmsnxtid'); + INSERT INTO ipinterface (id, nodeid, ipaddr, ismanaged, ipstatus, issnmpprimary) + VALUES (v_ifid, v_nodeid, '192.0.2.1', 'M', 1, 'P'); + RAISE NOTICE 'Inserted ipinterface id=% (192.0.2.1)', v_ifid; + + -- ── ifservices (ICMP = serviceid 3) ─────────────────────────────────────── + v_svcid := nextval('opennmsnxtid'); + INSERT INTO ifservices (id, ipinterfaceid, serviceid, status, source) + VALUES (v_svcid, v_ifid, 3, 'A', 'P'); + RAISE NOTICE 'Inserted ifservice id=% (ICMP)', v_svcid; + + -- ── Outages ─────────────────────────────────────────────────────────────── + -- Outage 1: Active (no ifregainedservice) + INSERT INTO outages (outageid, ifserviceid, iflostservice, ifregainedservice) + VALUES ( + nextval('outagenxtid'), + v_svcid, + NOW() - INTERVAL '2 hours', + NULL + ); + RAISE NOTICE 'Inserted active outage (lost 2h ago)'; + + -- Outage 2: Resolved ~10 min outage, 1 day ago + INSERT INTO outages (outageid, ifserviceid, iflostservice, ifregainedservice) + VALUES ( + nextval('outagenxtid'), + v_svcid, + NOW() - INTERVAL '1 day', + NOW() - INTERVAL '23 hours 50 minutes' + ); + RAISE NOTICE 'Inserted resolved outage (1 day ago)'; + + -- Outage 3: Resolved ~30 min outage, 2 days ago + INSERT INTO outages (outageid, ifserviceid, iflostservice, ifregainedservice) + VALUES ( + nextval('outagenxtid'), + v_svcid, + NOW() - INTERVAL '2 days', + NOW() - INTERVAL '1 day 23 hours 30 minutes' + ); + RAISE NOTICE 'Inserted resolved outage (2 days ago)'; + +END; +$$ LANGUAGE plpgsql; +SQL +``` + +The `--reset` flag cannot pass a variable into the heredoc `DO $$` block directly, so we use a PostgreSQL session-level setting. Replace the heredoc invocation with a two-step approach: + +```bash +# Set session var before the DO block based on $RESET +RESET_SETTING="SET app.reset = '$( [[ "$RESET" == "true" ]] && echo true || echo false )';" + +psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" \ + -c "$RESET_SETTING" \ + -f - <<'SQL' +DO $$ +... +SQL +``` + +> That `-c ... -f -` pattern won't work cleanly. Use a temp file approach instead — see full script below. + +Write the complete final script to `ui/seed-test-fixtures.sh`: + +```bash +#!/usr/bin/env bash +# seed-test-fixtures.sh +# Inserts UI-Test-Node with 1 active + 2 resolved ICMP outages into +# the running test-opennms container's PostgreSQL database. +# +# Usage: +# ./seed-test-fixtures.sh # no-op if already seeded +# ./seed-test-fixtures.sh --reset # drop UI-Test rows and re-insert +# +# DB connection (all overridable via env): +# PGHOST=localhost PGPORT=5432 PGUSER=opennms PGPASSWORD=opennms PGDATABASE=opennms + +set -euo pipefail + +PGHOST="${PGHOST:-localhost}" +PGPORT="${PGPORT:-5432}" +PGUSER="${PGUSER:-opennms}" +PGPASSWORD="${PGPASSWORD:-opennms}" +PGDATABASE="${PGDATABASE:-opennms}" +export PGPASSWORD + +RESET=false +if [[ "${1:-}" == "--reset" ]]; then + RESET=true +fi + +if ! command -v psql &>/dev/null; then + echo "ERROR: psql not found. Install with: brew install libpq" >&2 + exit 1 +fi + +if ! psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" -c "SELECT 1;" &>/dev/null; then + echo "ERROR: Cannot connect to PostgreSQL at $PGHOST:$PGPORT as $PGUSER" >&2 + exit 1 +fi + +echo "Connected to $PGDATABASE at $PGHOST:$PGPORT (reset=$RESET)" + +psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$PGDATABASE" < **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:** Stand up a 5-node leaf/spine virtual network (FRR + lldpd + net-snmp) alongside the test container, provisioned into OpenNMS so EnLinkd can discover and display real LLDP, OSPF, ISIS, Bridge, and IpNetToMedia topology. + +**Architecture:** A single `start-topology-lab.sh` script creates 7 podman networks, builds a custom `topology-node` image (FRR 9.1 + lldpd + net-snmp), starts 5 containers (2 spines + 3 leaves) with explicit interface-to-network assignments, attaches `test-opennms` to the management network, drops a requisition, and polls for OSPF convergence. All FRR configs, the Dockerfile, the entrypoint scripts, and the requisition XML are heredocs inside the script — no committed auxiliary files. The overlay image (`build-dark-mode-overlay.sh`) is extended to include a 30s EnLinkd rescan config and a SNMP range for 10.100.0.0/24. + +**Tech Stack:** podman, FRR 9.1 (ospfd + isisd + zebra), lldpd, net-snmp (snmpd with AgentX master), bash, OpenNMS REST API + +--- + +## File Map + +| File | Action | Purpose | +|---|---|---| +| `build-dark-mode-overlay.sh` | Modify | Add `enlinkd-configuration.xml` (30s rescan) + extend `snmp-config.xml` with topology-lab range | +| `start-topology-lab.sh` | Create | Full orchestration: image build, networks, containers, OpenNMS provisioning, convergence check | +| `stop-topology-lab.sh` | Create | Teardown helper | + +--- + +## Task 1: Extend the overlay image with EnLinkd config + SNMP range + +**Files:** +- Modify: `build-dark-mode-overlay.sh` + +The test container needs to be rebuilt after this task. EnLinkd's default rescan intervals are 86400000ms (24h) — useless for testing. We set them all to 30s. The SNMP range for 10.100.0.0/24 on port 161 tells OpenNMS how to poll the topology-lab nodes. + +- [ ] **Step 1: Update the `snmp-config.xml` heredoc** in `build-dark-mode-overlay.sh` (currently at the line starting `cat > "${OVERLAY_DIR}/etc/snmp-config.xml" <<'SNMPCFG'`). Replace the entire heredoc with: + +```bash +cat > "${OVERLAY_DIR}/etc/snmp-config.xml" <<'SNMPCFG' + + + 127.0.0.1 + + + + + +SNMPCFG +``` + +- [ ] **Step 2: Add the `enlinkd-configuration.xml` heredoc** immediately after the snmp-config block (before the `echo " snmpd.conf + ..."` line): + +```bash +cat > "${OVERLAY_DIR}/etc/enlinkd-configuration.xml" <<'ENLINKD' + + +ENLINKD +``` + +- [ ] **Step 3: Add the COPY directive** for `enlinkd-configuration.xml` in the Dockerfile heredoc (inside `build-dark-mode-overlay.sh`). Find the line: +``` +COPY --chown=10001:10001 etc/snmp-config.xml /opt/opennms/etc/snmp-config.xml +``` +Add immediately after it: +```dockerfile +COPY --chown=10001:10001 etc/enlinkd-configuration.xml /opt/opennms/etc/enlinkd-configuration.xml +``` + +- [ ] **Step 4: Update the echo confirmation line** (the one that says `snmpd.conf + entrypoint-wrapper.sh + snmp-config.xml + imports/Self.xml: staged`) to also mention enlinkd-configuration.xml: +```bash +echo " snmpd.conf + entrypoint-wrapper.sh + snmp-config.xml + enlinkd-configuration.xml + imports/Self.xml: staged" +``` + +- [ ] **Step 5: Rebuild the overlay image** +```bash +./build-dark-mode-overlay.sh 2>&1 | tail -20 +``` +Expected: `==> Build complete: localhost/opennms/horizon:35.0.5-dark-mode` + +- [ ] **Step 6: Verify enlinkd-configuration.xml is in the image** +```bash +podman run --rm --entrypoint cat localhost/opennms/horizon:35.0.5-dark-mode \ + /opt/opennms/etc/enlinkd-configuration.xml | grep lldp_rescan +``` +Expected: `lldp_rescan_interval="30000"` + +- [ ] **Step 7: Commit** +```bash +git add build-dark-mode-overlay.sh +git commit -m "feat(topology-lab): 30s enlinkd rescan + snmp range for test fabric" +``` + +--- + +## Task 2: Create `start-topology-lab.sh` — skeleton and topology-node image + +**Files:** +- Create: `start-topology-lab.sh` + +This task writes the script header, flag parsing, helper functions, and Phase 1 (image build). The topology-node Dockerfile, entrypoints, and snmpd.conf are all heredocs. + +- [ ] **Step 1: Create `start-topology-lab.sh`** with the following content: + +```bash +#!/usr/bin/env bash +# start-topology-lab.sh +# Spins up a 5-node leaf/spine topology lab alongside test-opennms. +# Nodes run FRR (OSPF + ISIS) + lldpd (LLDP) + net-snmp for EnLinkd testing. +# +# Usage: +# ./start-topology-lab.sh # start lab (teardown + rebuild if running) +# ./start-topology-lab.sh --teardown # teardown only +# ./start-topology-lab.sh --rebuild # force image rebuild + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +TOPO_IMAGE="localhost/opennms/topology-node:latest" +FRR_BASE="docker.io/frrouting/frr:9.1.0" + +TEARDOWN_ONLY=false +FORCE_REBUILD=false +for arg in "$@"; do + case "$arg" in + --teardown) TEARDOWN_ONLY=true ;; + --rebuild) FORCE_REBUILD=true ;; + esac +done + +# --------------------------------------------------------------------------- +# Node definitions: name, mgmt-ip, role, and P2P network assignments +# --------------------------------------------------------------------------- +# Format: "name mgmt-ip role net1:iface1:ip1 [net2:iface2:ip2] ..." +NODES=( + "topo-spine-01 10.100.0.11 spine topo-s1-l1:eth1:10.101.1.1 topo-s1-l2:eth2:10.101.2.1 topo-s1-l3:eth3:10.101.3.1" + "topo-spine-02 10.100.0.12 spine topo-s2-l1:eth1:10.101.4.1 topo-s2-l2:eth2:10.101.5.1 topo-s2-l3:eth3:10.101.6.1" + "topo-leaf-01 10.100.0.21 leaf topo-s1-l1:eth1:10.101.1.2 topo-s2-l1:eth2:10.101.4.2" + "topo-leaf-02 10.100.0.22 leaf topo-s1-l2:eth1:10.101.2.2 topo-s2-l2:eth2:10.101.5.2" + "topo-leaf-03 10.100.0.23 leaf topo-s1-l3:eth1:10.101.3.2 topo-s2-l3:eth2:10.101.6.2" +) + +MGMT_NET="topology-mgmt" +MGMT_SUBNET="10.100.0.0/24" +P2P_NETS=( + "topo-s1-l1:10.101.1.0/30" + "topo-s1-l2:10.101.2.0/30" + "topo-s1-l3:10.101.3.0/30" + "topo-s2-l1:10.101.4.0/30" + "topo-s2-l2:10.101.5.0/30" + "topo-s2-l3:10.101.6.0/30" +) + +# --------------------------------------------------------------------------- +# Teardown +# --------------------------------------------------------------------------- +teardown() { + echo "==> Tearing down topology lab..." + + # Remove containers + for node_def in "${NODES[@]}"; do + name=$(echo "$node_def" | awk '{print $1}') + podman rm -f "$name" 2>/dev/null && echo " removed container: $name" || true + done + + # Disconnect OpenNMS from management network + podman network disconnect "${MGMT_NET}" test-opennms 2>/dev/null && \ + echo " disconnected test-opennms from ${MGMT_NET}" || true + + # Remove staged FRR configs + rm -rf "${SCRIPT_DIR}/.topology-lab" 2>/dev/null && echo " removed staged configs" || true + + # Remove networks + podman network rm -f "${MGMT_NET}" 2>/dev/null && echo " removed network: ${MGMT_NET}" || true + for entry in "${P2P_NETS[@]}"; do + net="${entry%%:*}" + podman network rm -f "$net" 2>/dev/null && echo " removed network: $net" || true + done + + echo "==> Teardown complete." +} + +if [[ "${TEARDOWN_ONLY}" == "true" ]]; then + teardown + exit 0 +fi + +# Always tear down first for clean state +teardown + +# --------------------------------------------------------------------------- +# Phase 1: Build topology-node image +# --------------------------------------------------------------------------- +echo "" +echo "==> [1/7] Building topology-node image..." + +if podman image exists "${TOPO_IMAGE}" && [[ "${FORCE_REBUILD}" == "false" ]]; then + echo " Image already exists, skipping build (use --rebuild to force)" +else + BUILD_DIR="$(mktemp -d)" + trap 'rm -rf "$BUILD_DIR"' EXIT + + # ---- snmpd.conf ---- + cat > "${BUILD_DIR}/snmpd.conf" <<'SNMPD' +master agentx +agentXSocket /var/agentx/master +rocommunity public +syslocation "OpenNMS Topology Lab" +syscontact "admin@localhost" +SNMPD + + # ---- entrypoint.sh ---- + cat > "${BUILD_DIR}/entrypoint.sh" <<'ENTRY' +#!/bin/bash +set -e + +# Create AgentX socket directory +mkdir -p /var/agentx +chmod 755 /var/agentx + +# Start snmpd (master AgentX agent) — must be up before subagents connect +/usr/sbin/snmpd -Lo -p /tmp/snmpd.pid -c /etc/snmp/snmpd.conf +sleep 1 + +# For leaf nodes: create bridge interface for Bridge-MIB +if [[ "${ROLE}" == "leaf" ]]; then + ip link add br0 type bridge 2>/dev/null || true + ip link add dummy0 type dummy 2>/dev/null || true + ip link set dummy0 master br0 2>/dev/null || true + ip link set br0 up + ip link set dummy0 up +fi + +# Start lldpd as AgentX subagent (-x = AgentX, -d = debug/foreground logs) +lldpd -x & +sleep 1 + +# Start FRR (zebra + ospfd + isisd via watchfrr) +exec /usr/lib/frr/docker-start +ENTRY + chmod +x "${BUILD_DIR}/entrypoint.sh" + + # ---- Dockerfile ---- + cat > "${BUILD_DIR}/Dockerfile" <&1 | head -40 +``` +Expected: image build completes, you see `Build complete.` (will fail later when trying to create networks, that's fine — we haven't written those phases yet) + +- [ ] **Step 4: Verify the image was built and has the right tools** +```bash +podman run --rm localhost/opennms/topology-node:latest which lldpd +podman run --rm localhost/opennms/topology-node:latest which snmpd +podman run --rm localhost/opennms/topology-node:latest which ospfd +``` +Expected: `/usr/sbin/lldpd`, `/usr/sbin/snmpd`, `/usr/lib/frr/ospfd` (or similar paths) + +- [ ] **Step 5: Commit** +```bash +git add start-topology-lab.sh +git commit -m "feat(topology-lab): add start script skeleton + topology-node image build" +``` + +--- + +## Task 3: Phase 2 — Network creation + Phase 3 — FRR config staging + +**Files:** +- Modify: `start-topology-lab.sh` (append after the Phase 1 block) + +- [ ] **Step 1: Append the network creation phase** to `start-topology-lab.sh` (after the Phase 1 closing `fi`): + +```bash +# --------------------------------------------------------------------------- +# Phase 2: Create networks +# --------------------------------------------------------------------------- +echo "" +echo "==> [2/7] Creating podman networks..." + +podman network create --subnet "${MGMT_SUBNET}" "${MGMT_NET}" +echo " created: ${MGMT_NET} (${MGMT_SUBNET})" + +for entry in "${P2P_NETS[@]}"; do + net="${entry%%:*}" + subnet="${entry##*:}" + podman network create --subnet "${subnet}" "${net}" + echo " created: ${net} (${subnet})" +done + +# --------------------------------------------------------------------------- +# Phase 3: Stage per-node FRR configs +# --------------------------------------------------------------------------- +echo "" +echo "==> [3/7] Staging FRR configs..." + +# Fixed location so bind-mount paths survive manual container restarts +CONFIGS="${SCRIPT_DIR}/.topology-lab/configs" +rm -rf "${CONFIGS}" +mkdir -p "${CONFIGS}" +echo " config staging dir: ${CONFIGS}" + +# vtysh.conf is identical for all nodes +VTYSH_CONF="service integrated-vtysh-config" + +# ---- spine-01 ---- +mkdir -p "${CONFIGS}/spine-01" +cat > "${CONFIGS}/spine-01/daemons" <<'DAEMONS' +zebra=yes +ospfd=yes +isisd=yes +bgpd=no +ripd=no +ospf6d=no +DAEMONS +cat > "${CONFIGS}/spine-01/vtysh.conf" <<< "${VTYSH_CONF}" +cat > "${CONFIGS}/spine-01/frr.conf" <<'FRR' +frr version 9.1 +frr defaults traditional +hostname spine-01 +agentx +! +interface lo + ip address 10.255.0.11/32 +! +interface eth1 + description link-to-leaf-01 + ip address 10.101.1.1/30 + ip ospf area 0.0.0.0 + ip ospf network point-to-point + isis circuit-type level-2-only + isis network point-to-point +! +interface eth2 + description link-to-leaf-02 + ip address 10.101.2.1/30 + ip ospf area 0.0.0.0 + ip ospf network point-to-point + isis circuit-type level-2-only + isis network point-to-point +! +interface eth3 + description link-to-leaf-03 + ip address 10.101.3.1/30 + ip ospf area 0.0.0.0 + ip ospf network point-to-point + isis circuit-type level-2-only + isis network point-to-point +! +router ospf + ospf router-id 10.255.0.11 + network 10.255.0.11/32 area 0.0.0.0 +! +router isis FABRIC + net 49.0001.0aff.000b.00 + is-type level-2-only + metric-style wide +! +FRR + +# ---- spine-02 ---- +mkdir -p "${CONFIGS}/spine-02" +cat > "${CONFIGS}/spine-02/daemons" <<'DAEMONS' +zebra=yes +ospfd=yes +isisd=yes +bgpd=no +ripd=no +ospf6d=no +DAEMONS +cat > "${CONFIGS}/spine-02/vtysh.conf" <<< "${VTYSH_CONF}" +cat > "${CONFIGS}/spine-02/frr.conf" <<'FRR' +frr version 9.1 +frr defaults traditional +hostname spine-02 +agentx +! +interface lo + ip address 10.255.0.12/32 +! +interface eth1 + description link-to-leaf-01 + ip address 10.101.4.1/30 + ip ospf area 0.0.0.0 + ip ospf network point-to-point + isis circuit-type level-2-only + isis network point-to-point +! +interface eth2 + description link-to-leaf-02 + ip address 10.101.5.1/30 + ip ospf area 0.0.0.0 + ip ospf network point-to-point + isis circuit-type level-2-only + isis network point-to-point +! +interface eth3 + description link-to-leaf-03 + ip address 10.101.6.1/30 + ip ospf area 0.0.0.0 + ip ospf network point-to-point + isis circuit-type level-2-only + isis network point-to-point +! +router ospf + ospf router-id 10.255.0.12 + network 10.255.0.12/32 area 0.0.0.0 +! +router isis FABRIC + net 49.0001.0aff.000c.00 + is-type level-2-only + metric-style wide +! +FRR + +# ---- leaf-01 ---- +mkdir -p "${CONFIGS}/leaf-01" +cat > "${CONFIGS}/leaf-01/daemons" <<'DAEMONS' +zebra=yes +ospfd=yes +isisd=yes +bgpd=no +ripd=no +ospf6d=no +DAEMONS +cat > "${CONFIGS}/leaf-01/vtysh.conf" <<< "${VTYSH_CONF}" +cat > "${CONFIGS}/leaf-01/frr.conf" <<'FRR' +frr version 9.1 +frr defaults traditional +hostname leaf-01 +agentx +! +interface lo + ip address 10.255.0.21/32 +! +interface eth1 + description uplink-to-spine-01 + ip address 10.101.1.2/30 + ip ospf area 0.0.0.0 + ip ospf network point-to-point + isis circuit-type level-2-only + isis network point-to-point +! +interface eth2 + description uplink-to-spine-02 + ip address 10.101.4.2/30 + ip ospf area 0.0.0.0 + ip ospf network point-to-point + isis circuit-type level-2-only + isis network point-to-point +! +router ospf + ospf router-id 10.255.0.21 + network 10.255.0.21/32 area 0.0.0.0 +! +router isis FABRIC + net 49.0001.0aff.0015.00 + is-type level-2-only + metric-style wide +! +FRR + +# ---- leaf-02 ---- +mkdir -p "${CONFIGS}/leaf-02" +cat > "${CONFIGS}/leaf-02/daemons" <<'DAEMONS' +zebra=yes +ospfd=yes +isisd=yes +bgpd=no +ripd=no +ospf6d=no +DAEMONS +cat > "${CONFIGS}/leaf-02/vtysh.conf" <<< "${VTYSH_CONF}" +cat > "${CONFIGS}/leaf-02/frr.conf" <<'FRR' +frr version 9.1 +frr defaults traditional +hostname leaf-02 +agentx +! +interface lo + ip address 10.255.0.22/32 +! +interface eth1 + description uplink-to-spine-01 + ip address 10.101.2.2/30 + ip ospf area 0.0.0.0 + ip ospf network point-to-point + isis circuit-type level-2-only + isis network point-to-point +! +interface eth2 + description uplink-to-spine-02 + ip address 10.101.5.2/30 + ip ospf area 0.0.0.0 + ip ospf network point-to-point + isis circuit-type level-2-only + isis network point-to-point +! +router ospf + ospf router-id 10.255.0.22 + network 10.255.0.22/32 area 0.0.0.0 +! +router isis FABRIC + net 49.0001.0aff.0016.00 + is-type level-2-only + metric-style wide +! +FRR + +# ---- leaf-03 ---- +mkdir -p "${CONFIGS}/leaf-03" +cat > "${CONFIGS}/leaf-03/daemons" <<'DAEMONS' +zebra=yes +ospfd=yes +isisd=yes +bgpd=no +ripd=no +ospf6d=no +DAEMONS +cat > "${CONFIGS}/leaf-03/vtysh.conf" <<< "${VTYSH_CONF}" +cat > "${CONFIGS}/leaf-03/frr.conf" <<'FRR' +frr version 9.1 +frr defaults traditional +hostname leaf-03 +agentx +! +interface lo + ip address 10.255.0.23/32 +! +interface eth1 + description uplink-to-spine-01 + ip address 10.101.3.2/30 + ip ospf area 0.0.0.0 + ip ospf network point-to-point + isis circuit-type level-2-only + isis network point-to-point +! +interface eth2 + description uplink-to-spine-02 + ip address 10.101.6.2/30 + ip ospf area 0.0.0.0 + ip ospf network point-to-point + isis circuit-type level-2-only + isis network point-to-point +! +router ospf + ospf router-id 10.255.0.23 + network 10.255.0.23/32 area 0.0.0.0 +! +router isis FABRIC + net 49.0001.0aff.0017.00 + is-type level-2-only + metric-style wide +! +FRR + +echo " FRR configs staged for all 5 nodes." +``` + +- [ ] **Step 2: Run to verify networks and configs are created** +```bash +./start-topology-lab.sh 2>&1 | head -60 +``` +Expected output ends with `FRR configs staged for all 5 nodes.` (will fail at Phase 4 which doesn't exist yet) + +- [ ] **Step 3: Verify networks exist** +```bash +podman network ls | grep -E "topology-mgmt|topo-s" +``` +Expected: 7 networks listed + +- [ ] **Step 4: Teardown to reset** +```bash +./start-topology-lab.sh --teardown +podman network ls | grep -E "topology-mgmt|topo-s" +``` +Expected: no networks in the grep output + +- [ ] **Step 5: Commit** +```bash +git add start-topology-lab.sh +git commit -m "feat(topology-lab): add network creation + FRR config staging phases" +``` + +--- + +## Task 4: Phase 4 — Container launch + +**Files:** +- Modify: `start-topology-lab.sh` (append after Phase 3) + +- [ ] **Step 1: Append the container launch phase** to `start-topology-lab.sh`: + +```bash +# --------------------------------------------------------------------------- +# Phase 4: Start containers +# --------------------------------------------------------------------------- +echo "" +echo "==> [4/7] Starting topology nodes..." + +# Each entry in NODES: "name mgmt-ip role net1:iface1:ip1 ..." +for node_def in "${NODES[@]}"; do + read -ra parts <<< "$node_def" + name="${parts[0]}" + mgmt_ip="${parts[1]}" + role="${parts[2]}" + # Derive the short hostname (strip "topo-" prefix) + hostname="${name#topo-}" + # Derive frr config dir from hostname + frr_dir="${CONFIGS}/${hostname}" + + # Build --network args as array: management first (eth0), then P2P links + net_args=( + "--network" "${MGMT_NET}:interface_name=eth0,ip=${mgmt_ip}" + ) + for net_entry in "${parts[@]:3}"; do + net="${net_entry%%:*}" + rest="${net_entry#*:}" + iface="${rest%%:*}" + ip="${rest##*:}" + net_args+=("--network" "${net}:interface_name=${iface},ip=${ip}") + done + + podman run -d --privileged \ + --name "${name}" \ + --hostname "${hostname}" \ + -e "ROLE=${role}" \ + -v "${frr_dir}:/etc/frr:ro" \ + "${net_args[@]}" \ + "${TOPO_IMAGE}" + + echo " started: ${name} (mgmt: ${mgmt_ip}, role: ${role})" +done +``` + +- [ ] **Step 2: Run through Phase 4** +```bash +./start-topology-lab.sh 2>&1 | head -80 +``` +Expected: all 5 containers started (will fail at Phase 5 which doesn't exist yet) + +- [ ] **Step 3: Verify containers are running** +```bash +podman ps --filter "name=topo-" --format "{{.Names}}\t{{.Status}}" +``` +Expected: 5 rows, all `Up` + +- [ ] **Step 4: Spot-check FRR is running inside a spine** +```bash +podman exec topo-spine-01 vtysh -c "show version" 2>&1 | head -5 +``` +Expected: `FRRouting 9.1.0` (or similar) + +- [ ] **Step 5: Spot-check lldpd is running** +```bash +podman exec topo-spine-01 lldpcli show chassis | head -5 +``` +Expected: chassis info for spine-01 + +- [ ] **Step 6: Spot-check snmpd is running** +```bash +podman exec topo-spine-01 snmpwalk -v2c -c public 127.0.0.1 sysName.0 +``` +Expected: `SNMPv2-MIB::sysName.0 = STRING: spine-01` + +- [ ] **Step 7: Teardown + commit** +```bash +./start-topology-lab.sh --teardown +git add start-topology-lab.sh +git commit -m "feat(topology-lab): add container launch phase" +``` + +--- + +## Task 5: Phase 5+6 — OpenNMS attachment and requisition import + +**Files:** +- Modify: `start-topology-lab.sh` (append after Phase 4) + +- [ ] **Step 1: Append the OpenNMS integration phase** to `start-topology-lab.sh`: + +```bash +# --------------------------------------------------------------------------- +# Phase 5: Attach test-opennms to management network +# --------------------------------------------------------------------------- +echo "" +echo "==> [5/7] Attaching test-opennms to ${MGMT_NET}..." + +if podman network inspect "${MGMT_NET}" \ + --format '{{range .Containers}}{{.Name}} {{end}}' 2>/dev/null \ + | grep -q "test-opennms"; then + echo " already connected, skipping" +else + podman network connect \ + --ip 10.100.0.10 \ + "${MGMT_NET}" test-opennms + echo " connected test-opennms (10.100.0.10)" +fi + +# --------------------------------------------------------------------------- +# Phase 6: Drop requisition and trigger import +# --------------------------------------------------------------------------- +echo "" +echo "==> [6/7] Provisioning topology nodes into OpenNMS..." + +REQ_FILE="$(mktemp)" +cat > "${REQ_FILE}" <<'REQUISITION' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +REQUISITION + +podman cp "${REQ_FILE}" test-opennms:/opt/opennms/etc/imports/Topology-Lab.xml +rm -f "${REQ_FILE}" + +# Trigger import via REST (provisiond picks it up within seconds) +HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" \ + -u admin:notdefault \ + -X PUT \ + "http://localhost:8980/opennms/rest/requisitions/Topology-Lab/import?rescanExisting=true") +if [[ "${HTTP_CODE}" == "202" ]]; then + echo " Import triggered (HTTP 202)" +else + echo " WARNING: import REST call returned HTTP ${HTTP_CODE} (may still work if file was copied)" +fi +``` + +- [ ] **Step 2: Run through Phase 6** (requires `test-opennms` to be running and healthy) +```bash +./start-topology-lab.sh 2>&1 | grep -E "^\s*(==>| )" +``` +Expected: all 6 phases complete without errors + +- [ ] **Step 3: Verify test-opennms is on the management network** +```bash +podman network inspect topology-mgmt --format '{{range .Containers}}{{.Name}} {{end}}' +``` +Expected: output includes `test-opennms` and all 5 `topo-*` containers + +- [ ] **Step 4: Verify the requisition file landed** +```bash +podman exec test-opennms cat /opt/opennms/etc/imports/Topology-Lab.xml | grep node-label +``` +Expected: 5 `node-label` attributes (spine-01, spine-02, leaf-01, leaf-02, leaf-03) + +- [ ] **Step 5: Wait ~30s, then verify nodes appear in OpenNMS** +```bash +sleep 30 +curl -s -u admin:notdefault \ + "http://localhost:8980/opennms/rest/nodes?category=Topology-Lab" \ + | python3 -c "import sys,json; d=json.load(sys.stdin); print(f'{d[\"count\"]} nodes')" +``` +Expected: `5 nodes` + +- [ ] **Step 6: Teardown + commit** +```bash +./start-topology-lab.sh --teardown +git add start-topology-lab.sh +git commit -m "feat(topology-lab): add OpenNMS attach + requisition import phase" +``` + +--- + +## Task 6: Phase 7 — Convergence check and summary + +**Files:** +- Modify: `start-topology-lab.sh` (append after Phase 6) + +- [ ] **Step 1: Append the convergence check phase** to `start-topology-lab.sh`: + +```bash +# --------------------------------------------------------------------------- +# Phase 7: Wait for OSPF convergence +# --------------------------------------------------------------------------- +echo "" +echo "==> [7/7] Waiting for OSPF convergence (cap: 120s)..." + +CONVERGED=false +for i in $(seq 1 24); do + # Check spine-01 has 3 Full OSPF neighbors + count=$(podman exec topo-spine-01 vtysh -c "show ip ospf neighbor" 2>/dev/null \ + | grep -c "Full/" || true) + if [[ "${count}" -ge 3 ]]; then + CONVERGED=true + echo " OSPF converged after $((i * 5))s (spine-01 sees ${count} Full neighbors)" + break + fi + printf " ... waiting (%ds, spine-01 Full neighbors: %d/3)\r" "$((i * 5))" "${count}" + sleep 5 +done +echo "" + +if [[ "${CONVERGED}" == "false" ]]; then + echo "ERROR: OSPF did not converge within 120s. Diagnostics:" >&2 + echo "--- spine-01 ospf neighbors ---" >&2 + podman exec topo-spine-01 vtysh -c "show ip ospf neighbor" >&2 || true + echo "--- spine-01 isis neighbors ---" >&2 + podman exec topo-spine-01 vtysh -c "show isis neighbor" >&2 || true + echo "--- spine-01 lldp neighbors ---" >&2 + podman exec topo-spine-01 lldpcli show neighbors >&2 || true + exit 1 +fi + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- +echo "" +echo "============================================================" +echo " Topology Lab is UP" +echo "============================================================" +echo "" +echo " Nodes:" +for node_def in "${NODES[@]}"; do + read -ra parts <<< "$node_def" + printf " %-18s %s (%s)\n" "${parts[0]}" "${parts[1]}" "${parts[2]}" +done +echo "" +echo " Verify topology layers:" +echo " OSPF: podman exec topo-spine-01 vtysh -c 'show ip ospf neighbor'" +echo " ISIS: podman exec topo-spine-01 vtysh -c 'show isis neighbor'" +echo " LLDP: podman exec topo-spine-01 lldpcli show neighbors" +echo " SNMP: snmpwalk -v2c -c public 10.100.0.11 LLDP-MIB::lldpRemTable" +echo "" +echo " Wait ~30-60s for EnLinkd to collect, then open:" +echo " http://localhost:8980/opennms/ → navigate to /#/topology" +echo "" +echo " Teardown: ./start-topology-lab.sh --teardown" +echo "============================================================" +``` + +- [ ] **Step 2: Run the full script end-to-end** (requires running `test-opennms`) +```bash +./start-topology-lab.sh +``` +Expected: completes with `Topology Lab is UP` summary + +- [ ] **Step 3: Verify OSPF on both spines** +```bash +podman exec topo-spine-01 vtysh -c "show ip ospf neighbor" +podman exec topo-spine-02 vtysh -c "show ip ospf neighbor" +``` +Expected: each spine shows 3 neighbors in `Full/` state + +- [ ] **Step 4: Verify ISIS on spine-01** +```bash +podman exec topo-spine-01 vtysh -c "show isis neighbor" +``` +Expected: 3 entries in `Up` state + +- [ ] **Step 5: Verify LLDP on spine-01** +```bash +podman exec topo-spine-01 lldpcli show neighbors +``` +Expected: 3 neighbor entries (leaf-01, leaf-02, leaf-03) + +- [ ] **Step 6: Commit** +```bash +git add start-topology-lab.sh +git commit -m "feat(topology-lab): add convergence check + summary phase" +``` + +--- + +## Task 7: Create `stop-topology-lab.sh` + +**Files:** +- Create: `stop-topology-lab.sh` + +- [ ] **Step 1: Create `stop-topology-lab.sh`**: + +```bash +#!/usr/bin/env bash +# stop-topology-lab.sh — Tears down the topology lab. +# Equivalent to: ./start-topology-lab.sh --teardown +set -euo pipefail +exec "$(dirname "$0")/start-topology-lab.sh" --teardown +``` + +- [ ] **Step 2: Make executable and verify** +```bash +chmod +x stop-topology-lab.sh +./stop-topology-lab.sh +podman ps --filter "name=topo-" --format "{{.Names}}" +``` +Expected: no output (containers removed) + +- [ ] **Step 3: Commit** +```bash +git add stop-topology-lab.sh +git commit -m "feat(topology-lab): add stop-topology-lab.sh teardown helper" +``` + +--- + +## Task 8: End-to-end topology layer verification + +This task runs the full lab and verifies each topology layer is visible in OpenNMS via SNMP and the topology API. Run with `test-opennms` healthy and using the freshly rebuilt overlay image from Task 1. + +- [ ] **Step 1: Rebuild the overlay image** (picks up enlinkd-configuration.xml from Task 1) +```bash +./build-dark-mode-overlay.sh 2>&1 | tail -5 +``` +Expected: `==> Build complete: localhost/opennms/horizon:35.0.5-dark-mode` + +- [ ] **Step 2: Restart test-opennms with the new image** +```bash +podman rm -f test-opennms 2>/dev/null || true +podman run -d --name test-opennms --privileged \ + -p 8980:8980 -p 8101:8101 \ + -e POSTGRES_HOST=host.containers.internal \ + -e POSTGRES_PORT=5432 \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=postgres \ + -e OPENNMS_DBNAME=opennms \ + -e OPENNMS_DBUSER=opennms \ + -e OPENNMS_DBPASS=opennms \ + localhost/opennms/horizon:35.0.5-dark-mode -s +``` + +- [ ] **Step 3: Wait for OpenNMS to be healthy** +```bash +for i in $(seq 1 12); do + code=$(curl -s -o /dev/null -w "%{http_code}" -u admin:admin http://localhost:8980/opennms/rest/info || echo 0) + [[ "$code" == "200" ]] && echo "ready" && break + echo " waiting... (${i}/12)" + sleep 5 +done +``` +Expected: `ready` within 60s + +- [ ] **Step 4: Set password** +```bash +HASH=$(podman exec test-opennms java -cp /opt/opennms/lib/jasypt-1.9.3.jar \ + org.jasypt.intf.cli.JasyptStringDigestCLI \ + input='notdefault' algorithm=SHA-256 saltSizeBytes=16 iterations=100000 2>/dev/null \ + | tail -3 | head -1) +podman exec test-opennms sed -i \ + "s|.*|${HASH}|" \ + /opt/opennms/etc/users.xml +``` + +- [ ] **Step 5: Start the topology lab** +```bash +./start-topology-lab.sh +``` +Expected: `Topology Lab is UP` + +- [ ] **Step 6: Verify SNMP LLDP-MIB on spine-01** +```bash +snmpwalk -v2c -c public 10.100.0.11 LLDP-MIB::lldpRemTable 2>&1 | head -20 +``` +Expected: OID entries for 3 remote neighbors + +- [ ] **Step 7: Verify SNMP OSPF-MIB on spine-01** +```bash +snmpwalk -v2c -c public 10.100.0.11 OSPF-MIB::ospfNbrTable 2>&1 | head -20 +``` +Expected: entries for 3 OSPF neighbors + +- [ ] **Step 8: Verify Bridge-MIB on leaf-01** +```bash +snmpwalk -v2c -c public 10.100.0.21 BRIDGE-MIB::dot1dBase 2>&1 +``` +Expected: `dot1dBaseNumPorts`, `dot1dBaseType` entries for br0 + +- [ ] **Step 9: Verify IpNetToMedia on spine-01** +```bash +snmpwalk -v2c -c public 10.100.0.11 IP-MIB::ipNetToMediaTable 2>&1 | head -10 +``` +Expected: ARP entries showing IPs on the P2P subnets + +- [ ] **Step 10: Wait for EnLinkd to collect (~60s), then check topology API** +```bash +sleep 60 +curl -s -u admin:notdefault \ + http://localhost:8980/opennms/api/v2/graphs \ + | python3 -m json.tool | grep -A2 '"id"' +``` +Expected: enlinkd graph container entries (lldp, ospf, isis, bridge, ipnettomedia namespaces) + +- [ ] **Step 11: Open topology viewer and visually confirm leaf/spine graph** + +Navigate to `http://localhost:8980/opennms/` → `/#/topology`. Select the EnLinkd LLDP layer. Expected: 5 nodes with spine-01 and spine-02 connected to all three leaf nodes, no cross-spine or cross-leaf links. + +- [ ] **Step 12: Final commit** +```bash +git add docs/superpowers/plans/2026-04-05-topology-lab.md +git commit -m "docs: add topology lab implementation plan" +``` diff --git a/docs/superpowers/plans/2026-04-06-app-wide-design-language.md b/docs/superpowers/plans/2026-04-06-app-wide-design-language.md new file mode 100644 index 000000000000..e747896c6081 --- /dev/null +++ b/docs/superpowers/plans/2026-04-06-app-wide-design-language.md @@ -0,0 +1,1363 @@ +# App-Wide Design Language — Problem-First Perspective 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:** Implement a global "Problems | Full" perspective toggle that silences healthy content by default across Node Detail, Alarms, Outages, and Surveillance Dashboard. + +**Architecture:** A Pinia store (`usePerspectiveStore`) holds a single `'problems' | 'full'` value persisted to localStorage. A reusable `PerspectiveToggle.vue` pill renders on each applicable page. Components read `perspectiveStore.isProblems` (or receive it as a prop) and suppress healthy content accordingly. Three shared utility components (`ClearSummary`, `CollapsibleSection`, `StatusSummaryLine`) provide the visual language for collapsed/healthy state. + +**Tech Stack:** Vue 3 + Pinia, Feather DS (`@featherds/styles`), SCSS with `var($feather-*)` tokens. Build: `yarn build` in `ui/`. Deploy: `./deploy-to-container.sh test-opennms`. + +--- + +## File Map + +### New files +| File | Purpose | +|------|---------| +| `ui/src/stores/perspectiveStore.ts` | Global perspective state (`'problems' \| 'full'`) with localStorage persistence | +| `ui/src/components/Common/PerspectiveToggle.vue` | Two-segment pill button rendered on applicable pages | +| `ui/src/components/Common/ClearSummary.vue` | "✓ All X healthy" line replacing suppressed sections | +| `ui/src/components/Common/CollapsibleSection.vue` | Collapsible card wrapper for reference sections | +| `ui/src/components/Common/StatusSummaryLine.vue` | "Showing X of Y with problems" line for list pages | + +### Modified files +| File | Change | +|------|--------| +| `ui/src/containers/NodeDetails.vue` | Add page-controls bar (toggle + View Graphs link); wrap info/cat in CollapsibleSection; pass `problemsOnly` to sub-panels; hide ResourceGraphsPanel in problems mode | +| `ui/src/components/NodeDetail/AvailabilityPanel.vue` | Accept `problemsOnly` prop; filter 100% service cards; show ClearSummary when all healthy | +| `ui/src/components/NodeDetail/NetworkTab.vue` | Accept `problemsOnly` prop; filter to down-only endpoints; show ClearSummary when all up | +| `ui/src/components/Nodes/AlarmsTable.vue` | Accept `extraFiql?: string` prop; merge into FIQL query | +| `ui/src/components/Nodes/OutagesTable.vue` | Accept `filterFiql?: string` prop; merge into query params | +| `ui/src/components/NodeDetail/NodeActivityTab.vue` | Read perspectiveStore; pass active-only FIQL to AlarmsTable + OutagesTable | +| `ui/src/containers/Alarms.vue` | Add PerspectiveToggle | +| `ui/src/components/Alarms/AlarmsListTable.vue` | Watch perspectiveStore; reset ackStatus + severities on perspective change | +| `ui/src/containers/Outages.vue` | Add PerspectiveToggle | +| `ui/src/components/Outages/OutagesListTable.vue` | Watch perspectiveStore; reset statusFilter on perspective change | +| `ui/src/containers/SurveillanceDashboard.vue` | Add PerspectiveToggle; pass `dimHealthy` to SurveillanceGrid | +| `ui/src/components/SurveillanceDashboard/SurveillanceGrid.vue` | Accept `dimHealthy` prop; reduce opacity on NORMAL cells with 0 downCount | + +--- + +## Task 1: Perspective Store + +**Files:** +- Create: `ui/src/stores/perspectiveStore.ts` + +- [ ] **Step 1: Create the store** + +```ts +// ui/src/stores/perspectiveStore.ts +import { defineStore } from 'pinia' + +const STORAGE_KEY = 'onms.perspective' + +export const usePerspectiveStore = defineStore('perspectiveStore', () => { + const perspective = ref<'problems' | 'full'>( + (localStorage.getItem(STORAGE_KEY) as 'problems' | 'full') ?? 'problems' + ) + + const isProblems = computed(() => perspective.value === 'problems') + + function setPerspective(value: 'problems' | 'full') { + perspective.value = value + localStorage.setItem(STORAGE_KEY, value) + } + + return { perspective, isProblems, setPerspective } +}) +``` + +- [ ] **Step 2: Commit** + +```bash +git add ui/src/stores/perspectiveStore.ts +git commit -m "feat(perspective): add usePerspectiveStore with localStorage persistence" +``` + +--- + +## Task 2: Shared Components — PerspectiveToggle, ClearSummary, CollapsibleSection, StatusSummaryLine + +**Files:** +- Create: `ui/src/components/Common/PerspectiveToggle.vue` +- Create: `ui/src/components/Common/ClearSummary.vue` +- Create: `ui/src/components/Common/CollapsibleSection.vue` +- Create: `ui/src/components/Common/StatusSummaryLine.vue` + +- [ ] **Step 1: Create PerspectiveToggle.vue** + +```vue + + + + + + +``` + +- [ ] **Step 2: Create ClearSummary.vue** + +```vue + + + + + + +``` + +- [ ] **Step 3: Create CollapsibleSection.vue** + +```vue + + + + + + +``` + +- [ ] **Step 4: Create StatusSummaryLine.vue** + +```vue + + + + + + +``` + +- [ ] **Step 5: Commit** + +```bash +git add ui/src/components/Common/PerspectiveToggle.vue \ + ui/src/components/Common/ClearSummary.vue \ + ui/src/components/Common/CollapsibleSection.vue \ + ui/src/components/Common/StatusSummaryLine.vue +git commit -m "feat(perspective): add shared PerspectiveToggle, ClearSummary, CollapsibleSection, StatusSummaryLine" +``` + +--- + +## Task 3: Node Detail — Availability Panel Problems Mode + +**Files:** +- Modify: `ui/src/components/NodeDetail/AvailabilityPanel.vue` + +The panel currently shows all service cards for every IP interface. In problems mode, filter out services at 100% availability. If all services are 100%, replace the entire panel with a ClearSummary. + +- [ ] **Step 1: Add `problemsOnly` prop and filter logic** + +In `AvailabilityPanel.vue`, add the prop and a computed for filtered interfaces. Replace the `availability.ipinterfaces` loop with `filteredInterfaces`. Add ClearSummary import and usage. + +The full updated ` + + + + diff --git a/ui/src/containers/Alarms.vue b/ui/src/containers/Alarms.vue new file mode 100644 index 000000000000..94fd09f8e90c --- /dev/null +++ b/ui/src/containers/Alarms.vue @@ -0,0 +1,63 @@ + + + + + + diff --git a/ui/src/containers/BusinessServicesAdmin.vue b/ui/src/containers/BusinessServicesAdmin.vue new file mode 100644 index 000000000000..e1ed02a6b362 --- /dev/null +++ b/ui/src/containers/BusinessServicesAdmin.vue @@ -0,0 +1,326 @@ + + + + + + + diff --git a/ui/src/containers/Dashboard.vue b/ui/src/containers/Dashboard.vue new file mode 100644 index 000000000000..eebd0621f0cf --- /dev/null +++ b/ui/src/containers/Dashboard.vue @@ -0,0 +1,188 @@ + + + + + + diff --git a/ui/src/containers/EventConfigurationDetail.vue b/ui/src/containers/EventConfigurationDetail.vue index 09beddf7289d..fac4255b2f4b 100644 --- a/ui/src/containers/EventConfigurationDetail.vue +++ b/ui/src/containers/EventConfigurationDetail.vue @@ -139,6 +139,7 @@ onMounted(async () => { diff --git a/ui/src/containers/JmxConfigGenerator.vue b/ui/src/containers/JmxConfigGenerator.vue new file mode 100644 index 000000000000..31e7be5480b7 --- /dev/null +++ b/ui/src/containers/JmxConfigGenerator.vue @@ -0,0 +1,158 @@ + + + + + + + diff --git a/ui/src/containers/MibCompiler.vue b/ui/src/containers/MibCompiler.vue new file mode 100644 index 000000000000..6248ea356be3 --- /dev/null +++ b/ui/src/containers/MibCompiler.vue @@ -0,0 +1,299 @@ + + + + + + + diff --git a/ui/src/containers/NodeDetails.vue b/ui/src/containers/NodeDetails.vue index 664004acbf68..b3ac21483ff3 100644 --- a/ui/src/containers/NodeDetails.vue +++ b/ui/src/containers/NodeDetails.vue @@ -1,47 +1,206 @@ - + + + diff --git a/ui/src/containers/OutageDetail.vue b/ui/src/containers/OutageDetail.vue new file mode 100644 index 000000000000..df60f74b2fce --- /dev/null +++ b/ui/src/containers/OutageDetail.vue @@ -0,0 +1,218 @@ + + + + + + diff --git a/ui/src/containers/Outages.vue b/ui/src/containers/Outages.vue new file mode 100644 index 000000000000..29c2aabbbe03 --- /dev/null +++ b/ui/src/containers/Outages.vue @@ -0,0 +1,63 @@ + + + + + + diff --git a/ui/src/containers/SnmpCollectionsConfig.vue b/ui/src/containers/SnmpCollectionsConfig.vue new file mode 100644 index 000000000000..270c7eecb59d --- /dev/null +++ b/ui/src/containers/SnmpCollectionsConfig.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/ui/src/containers/SurveillanceDashboard.vue b/ui/src/containers/SurveillanceDashboard.vue new file mode 100644 index 000000000000..f76be8d1d2cd --- /dev/null +++ b/ui/src/containers/SurveillanceDashboard.vue @@ -0,0 +1,269 @@ + + + + + + diff --git a/ui/src/containers/SurveillanceViewsConfig.vue b/ui/src/containers/SurveillanceViewsConfig.vue new file mode 100644 index 000000000000..6df22937237b --- /dev/null +++ b/ui/src/containers/SurveillanceViewsConfig.vue @@ -0,0 +1,227 @@ + + + + + + + diff --git a/ui/src/containers/Topology.vue b/ui/src/containers/Topology.vue new file mode 100644 index 000000000000..17c9a268295b --- /dev/null +++ b/ui/src/containers/Topology.vue @@ -0,0 +1,156 @@ + + + + + diff --git a/ui/src/containers/WallboardConfig.vue b/ui/src/containers/WallboardConfig.vue new file mode 100644 index 000000000000..c3102cb34e40 --- /dev/null +++ b/ui/src/containers/WallboardConfig.vue @@ -0,0 +1,212 @@ + + + + + + + diff --git a/ui/src/containers/ZenithConnectSuccess.vue b/ui/src/containers/ZenithConnectSuccess.vue index 20ba450af4ef..493992ef744d 100644 --- a/ui/src/containers/ZenithConnectSuccess.vue +++ b/ui/src/containers/ZenithConnectSuccess.vue @@ -132,7 +132,7 @@ const onSaveValues = async () => { } const onViewConnections = () => { - router.push('zenith-connect') + router.push('/zenith-connect') } onMounted(() => { @@ -148,6 +148,7 @@ onMounted(() => {