diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..be9d353 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,61 @@ +name: test + +on: + push: + pull_request: + +jobs: + # The core tiers install nothing, so this job is fast and cannot be broken + # by a registry outage. It is also the job that proves a bare checkout works: + # there is no ipplan database here, so the real-data tests skip. + core: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # ipplan2dhmap.py runs on whatever python the server ships, so cover + # both the oldest still-supported version and the newest. + python-version: ['3.12', '3.14'] + steps: + - uses: actions/checkout@v7 + + - uses: actions/setup-node@v7 + with: + node-version: '24' + + - uses: actions/setup-python@v7 + with: + python-version: ${{ matrix.python-version }} + + - name: JS logic + run: make test-unit + + - name: Python, servers and fake backend + run: make test-py + + # jsdom, Playwright and a browser. Uses the same image as test/podman.sh so + # local and CI runs are the same environment. + browser: + runs-on: ubuntu-latest + container: + image: mcr.microsoft.com/playwright:v1.62.0-noble + steps: + - uses: actions/checkout@v7 + + - name: Install dependencies + run: npm ci --no-audit --no-fund + + - name: Menu under jsdom + run: node --test "test/dom/*.test.js" + + - name: Full app in chromium + run: npx playwright test + + - if: failure() + uses: actions/upload-artifact@v7 + with: + name: playwright-report + path: | + playwright-report/ + test-results/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index fe107c2..2fde25d 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,17 @@ -/data.json \ No newline at end of file +/data.json + +# Test outputs - never commit anything a test run produces +node_modules/ +test-results/ +playwright-report/ +.playwright/ +__pycache__/ +*.pyc + +# Real ipplan databases must never be committed (they contain host +# addressing and network plans). Drop them in local/ for local testing. +*.db +*.db.xz +/local/* +!/local/.gitkeep +!/local/README.md diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..df41431 --- /dev/null +++ b/Makefile @@ -0,0 +1,76 @@ +# dhmap - development and test entry points. +# +# The core tiers need nothing but python3 and node, which is why they are kept +# dependency-free. The browser tiers need jsdom, Playwright and a browser, so +# they run in a container and install nothing on the host. + +PYTHON ?= python3 +NODE ?= node +PORT ?= 8000 +BACKEND_PORT ?= 5000 +SCENARIO ?= degraded + +PODMAN_RUN := ./test/podman.sh + +.PHONY: test test-unit test-py test-dom test-e2e serve data clean help + +help: + @echo 'make test run every tier whose dependencies are available' + @echo 'make test-unit JS logic (no install)' + @echo 'make test-py python + servers (no install)' + @echo 'make test-dom menu under jsdom (podman)' + @echo 'make test-e2e full app in chromium (podman)' + @echo 'make serve run the app locally with a fake backend' + @echo 'make clean remove generated outputs' + @echo + @echo 'Drop a real ipplan database in local/ to work against real data;' + @echo 'see local/README.md.' + +# Tier 1 - no dependencies beyond node and python3. +test-unit: + $(NODE) --test "test/unit/*.test.js" + +test-py: + $(PYTHON) -m unittest discover -s test/python -t test/python + +# Tiers 2 and 3 - containerised, so nothing is installed on the host. +test-dom: + $(PODMAN_RUN) '$(NODE) --test "test/dom/*.test.js"' + +test-e2e: + $(PODMAN_RUN) 'playwright test' + +# Runs what it can: the core tiers always, the browser tiers when podman is +# available. Keeps a bare checkout useful without failing on missing tools. +test: test-unit test-py + @if command -v podman >/dev/null 2>&1; then \ + $(MAKE) test-dom test-e2e; \ + else \ + echo; \ + echo 'podman not found - skipping the browser tiers (test-dom, test-e2e).'; \ + fi + +# data.json is a build output: generated from a real ipplan database when one +# is present, otherwise from the example. +data: + @$(PYTHON) test/data_source.py + +serve: data + @echo "map http://localhost:$(PORT)/dhmon.html" + @echo "example http://localhost:$(PORT)/src/examples/" + @echo "scenario $(SCENARIO)" + @echo + @$(PYTHON) test/fake/backend.py --port $(BACKEND_PORT) \ + --scenario $(SCENARIO) \ + --devices `$(PYTHON) test/data_source.py --devices` & \ + trap 'kill %1 2>/dev/null' EXIT INT TERM; \ + $(PYTHON) localserver.py --port $(PORT) \ + --analytics-port $(BACKEND_PORT) + +clean: + rm -f data.json + rm -rf test-results playwright-report + find . -name __pycache__ -type d -not -path './src/vendor/*' \ + -exec rm -rf {} + 2>/dev/null || true + @echo 'Removed generated outputs. The container dependency volume is' + @echo 'kept; remove it with: podman volume rm dhmap-deps' diff --git a/README.md b/README.md index 4da7057..e557b62 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,28 @@ HTML5/JS library for drawing and updating network layouts # Try it - cd src/ - python -m SimpleHTTPServer + make serve -Go to `http://localhost:8000/examples/` +Go to `http://localhost:8000/dhmon.html` for the map, or +`http://localhost:8000/src/examples/` for a minimal usage example. + +`make serve` runs the local web server together with a fake analytics backend, +so the map is populated without needing a real dhmon behind it. To render a +real event instead, drop an ipplan database into `local/` first — see +[local/README.md](local/README.md). + +# Tests + + make test + +Runs everything that has its dependencies available. The core suites need only +`python3` and `node`: + + make test-unit # JS logic + make test-py # ipplan2dhmap.py, localserver, fake backend + +The browser suites additionally need `npm ci` and +`npx playwright install chromium`: + + make test-dom # menu rendering under jsdom + make test-e2e # full app in headless chromium diff --git a/local/.gitkeep b/local/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/local/README.md b/local/README.md new file mode 100644 index 0000000..a589208 --- /dev/null +++ b/local/README.md @@ -0,0 +1,33 @@ +# local/ + +Scratch directory for local development. **Everything here is gitignored.** + +## Running against real event data + +Drop a real ipplan database here and every command picks it up automatically: + + cp ~/Downloads/ipplan.db.xz local/ + +`.xz` files are decompressed to a temp directory transparently, so you can drop +the file in exactly as it arrives. Then: + + make serve # serves the real event map on localhost:8000 + make test-py # additionally runs the assertions against the real database + +Resolution order, used consistently everywhere: + +1. `$IPPLAN_DB` +2. `local/ipplan.db` +3. `local/ipplan.db.xz` +4. a generated fixture topology (the default, and what CI uses) + +With no database present everything still works — the suite falls back to a +fixture topology and the real-data tests report as skipped. + +## Do not commit real data + +A real `ipplan.db` contains host records with IPv4/IPv6 addressing and network +definitions with VLANs, netmasks and gateways. This repository is public *and* +is deployed verbatim to `/var/www/html/dhmap` by puppet, so anything committed +here becomes web-served. `.gitignore` covers `local/`, `*.db` and `*.db.xz` to +make that mistake hard to make. diff --git a/localserver.py b/localserver.py index 0bdfa8c..7511dc0 100755 --- a/localserver.py +++ b/localserver.py @@ -1,24 +1,122 @@ #!/usr/bin/env python3 -# Used to run dhmap + analytics on the same machine for dev purposes +"""Serve dhmap locally, proxying /analytics/ to the analytics backend. -import SimpleHTTPServer -import SocketServer +Used to run dhmap + analytics on the same machine for dev purposes. The +analytics backend is normally dhmon's; for local work without one, see +test/fake/backend.py, which serves the same endpoints from a scenario. -PORT = 8000 +The proxy mirrors production, where apache is configured with +`ProxyPass /analytics http://localhost:5000` (see the dhmon::analytics puppet +class), so /analytics/ping.status is served from the backend's /ping.status +and the browser sees a single origin. +""" +import argparse +import functools +import http.server +import os +import socketserver +import urllib.error +import urllib.request + +DEFAULT_PORT = 8000 +DEFAULT_ANALYTICS_PORT = 5000 +# Matches the ProxyPass url in the dhmon::analytics puppet class. +ANALYTICS_PREFIX = '/analytics' + + +class RevHandler(http.server.SimpleHTTPRequestHandler): + """Static file handler that reverse-proxies /analytics/ to the backend.""" + + analytics_port = DEFAULT_ANALYTICS_PORT + # Long enough to survive a slow backend, short enough not to hang a reload. + proxy_timeout = 10 -class RevHandler(SimpleHTTPServer.SimpleHTTPRequestHandler): def do_GET(self): - if self.path.startswith('/analytics/'): - path = self.path[len('/analytics/'):] - self.send_response(302) - self.send_header("Location", "http://localhost:5000/" + path) + if self.path.startswith(ANALYTICS_PREFIX): + # ProxyPass strips the prefix, so /analytics/ping.status is the + # backend's /ping.status. + return self._proxy(self.path[len(ANALYTICS_PREFIX):]) + return http.server.SimpleHTTPRequestHandler.do_GET(self) + + def _proxy(self, path): + url = 'http://localhost:%d%s' % (self.analytics_port, path) + + # Fetch first, then relay. Writing to the client inside this try would + # let a disconnected browser - BrokenPipeError is an OSError - be + # misreported as the backend being unreachable. + try: + with urllib.request.urlopen(url, timeout=self.proxy_timeout) as upstream: + status, headers, body = ( + upstream.status, upstream.headers, upstream.read()) + except urllib.error.HTTPError as error: + # Pass the backend's own error through rather than masking it. + with error: + status, headers, body = error.code, error.headers, error.read() + except (urllib.error.URLError, OSError) as error: + return self._safely(self.send_error, 502, + 'analytics backend unreachable at %s: %s' + % (url, error)) + + self._relay(status, headers, body) + + def _relay(self, status, headers, body): + def write(): + self.send_response(status) + content_type = headers.get('Content-Type') + if content_type: + self.send_header('Content-Type', content_type) + self.send_header('Content-Length', str(len(body))) self.end_headers() - return None - else: - SimpleHTTPServer.SimpleHTTPRequestHandler.do_GET(self) + self.wfile.write(body) + + self._safely(write) + + def _safely(self, action, *args): + """Run `action`, tolerating the client having gone away. + + The map polls every 10 seconds, so a reload or a closed tab routinely + drops a connection mid-response. That is normal, not an error worth a + traceback. + """ + try: + action(*args) + except (BrokenPipeError, ConnectionResetError): + self.close_connection = True + + +def serve(port=DEFAULT_PORT, analytics_port=DEFAULT_ANALYTICS_PORT, + directory=None, quiet=False): + """Create a server. Returns it without serving, so tests can drive it. + + Pass port=0 to bind an arbitrary free port; read it back from + httpd.server_address[1]. Pass quiet=True to suppress request logging. + """ + # Subclass per server so concurrent servers can target different backends; + # setting the attribute on a functools.partial would not reach the class. + overrides = {'analytics_port': analytics_port} + if quiet: + overrides['log_message'] = lambda self, *args: None + handler = type('BoundRevHandler', (RevHandler,), overrides) + handler = functools.partial( + handler, + directory=directory or os.path.dirname(os.path.abspath(__file__))) + # Without this a restart within TIME_WAIT fails to bind. + socketserver.TCPServer.allow_reuse_address = True + return socketserver.TCPServer(('', port), handler) -httpd = SocketServer.TCPServer(("", PORT), RevHandler) -print("serving at port {}".format(PORT)) -httpd.serve_forever() +if __name__ == '__main__': + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('--port', type=int, default=DEFAULT_PORT, + help='port to serve on (default: %(default)s)') + parser.add_argument('--analytics-port', type=int, + default=DEFAULT_ANALYTICS_PORT, + help='analytics backend port (default: %(default)s)') + args = parser.parse_args() + httpd = serve(args.port, args.analytics_port) + print('serving at port {}'.format(args.port)) + try: + httpd.serve_forever() + except KeyboardInterrupt: + httpd.shutdown() diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..daefee4 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,619 @@ +{ + "name": "dhmap", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "dhmap", + "version": "0.0.0", + "license": "BSD-3-Clause", + "devDependencies": { + "@playwright/test": "^1.62.0", + "jsdom": "^30.0.0" + } + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.5.tgz", + "integrity": "sha512-mbhpPMmnw/kwW19aRNmSUl1QzLbdGo1SCuE49BT98MNwqF6zaHb3o2owssFc/PEO/4t2UjqtCNwocuDtJornzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.2.1", + "@csstools/css-color-parser": "^4.1.9", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.0.tgz", + "integrity": "sha512-UJLfKXBhrc8i1vH2eJXuYQMwlsLKWFw3O+CPqXSuVEiikeAim3UgrfWX0k4tA/X8cRFM8iZ7OaqBokFGbYusdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.10", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.10.tgz", + "integrity": "sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.7.tgz", + "integrity": "sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@playwright/test": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz", + "integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "30.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.0.tgz", + "integrity": "sha512-JQHfRGmmKmaZoUAvIgff5jjG/0SzTQlGz8c7t72KzBzo8ZULEjAjnYE0sNwBOUA4QtWwYE2xoYitg8NFsmiYxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.2.5", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.6", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.7.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/playwright": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz", + "integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.62.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.62.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz", + "integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tldts": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.9.tgz", + "integrity": "sha512-3kZ8wQQ/k5DrChD4X4FVvr2D7E5uoRgAqkPyLpSCGUvqOvqu+JEdr3mwMUaVWb+vMHZaKhF5fp2PBigKsui7hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.9" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.9", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.9.tgz", + "integrity": "sha512-DxKfPBI52p2msTEu7MPhdpdDTBhhVQg1a/8PjQckeyAvO13eMYElX545grIp6nnTGIMZlRvFZPvFhvI/WIz2Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/undici": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..1f854c4 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "dhmap", + "version": "0.0.0", + "private": true, + "description": "HTML5/JS library for drawing and updating network layouts", + "license": "BSD-3-Clause", + "scripts": { + "test": "make test", + "test:unit": "node --test \"test/unit/*.test.js\"", + "test:dom": "node --test \"test/dom/*.test.js\"", + "test:e2e": "playwright test" + }, + "devDependencies": { + "@playwright/test": "^1.62.0", + "jsdom": "^30.0.0" + } +} diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..a6040b8 --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,47 @@ +// Browser tier configuration. +// +// Runs the real development server plus the fake monitoring backend, so the +// whole chain - page, /analytics redirect, backend - is exercised rather than +// stubbed at the network layer. +const { defineConfig, devices } = require('@playwright/test'); + +const APP_PORT = 8000; +const BACKEND_PORT = 5000; + +module.exports = defineConfig({ + testDir: './test/e2e', + // Always render the example, never whatever ipplan database happens to be + // sitting in local/ - these assertions name specific switches. + globalSetup: require.resolve('./test/e2e/global-setup.js'), + fullyParallel: false, // both specs share one fake backend + workers: 1, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list', + + use: { + baseURL: `http://127.0.0.1:${APP_PORT}`, + trace: 'retain-on-failure', + }, + + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + ], + + webServer: [ + { + command: `python3 test/fake/backend.py --port ${BACKEND_PORT}` + + ' --scenario degraded', + url: `http://127.0.0.1:${BACKEND_PORT}/-/healthy`, + reuseExistingServer: !process.env.CI, + stdout: 'ignore', + }, + { + command: `python3 localserver.py --port ${APP_PORT}` + + ` --analytics-port ${BACKEND_PORT}`, + url: `http://127.0.0.1:${APP_PORT}/dhmon.html`, + reuseExistingServer: !process.env.CI, + stdout: 'ignore', + }, + ], +}); diff --git a/src/examples/data.json b/src/examples/data.json index 9e52659..95aecff 100644 --- a/src/examples/data.json +++ b/src/examples/data.json @@ -1,68 +1,118 @@ -[ { - "name": "D73", - "horizontal": 0, - "class": "table", - "x1": 67, - "y1": 0, - "x2": 59, - "y2": 37, - "width": 38, - "height": 8 -}, -{ - "name": "D74", - "horizontal": 0, - "class": "table", - "x1": 50, - "y1": 0, - "x2": 42, - "y2": 37, - "width": 38, - "height": 8 -}, -{ - "name": "D75", - "horizontal": 0, - "class": "table", - "x1": 33, - "y1": 0, - "x2": 25, - "y2": 37, - "width": 38, - "height": 8 -}, -{ - "name": "d73-a.event.dreamhack.local", - "horizontal": 0, - "class": "switch", - "x1": 68, - "y1": 22, - "x2": 68, - "y2": 22, - "width": 5, - "height": 5 -}, -{ - "name": "d74-a.event.dreamhack.local", - "horizontal": 0, - "class": "switch", - "x1": 51, - "y1": 22, - "x2": 51, - "y2": 22, - "width": 5, - "height": 5 -}, -{ - "name": "d75-a.event.dreamhack.local", - "horizontal": 0, - "class": "switch", - "x1": 34, - "y1": 22, - "x2": 34, - "y2": 22, - "width": 5, - "height": 5 + "Hall 1": [ + { + "name": "D73", + "horizontal": 0, + "class": "table", + "hall": "Hall 1", + "x1": 67, + "y1": 0, + "x2": 59, + "y2": 37, + "width": 38, + "height": 8 + }, + { + "name": "D74", + "horizontal": 0, + "class": "table", + "hall": "Hall 1", + "x1": 50, + "y1": 0, + "x2": 42, + "y2": 37, + "width": 38, + "height": 8 + }, + { + "name": "D75", + "horizontal": 0, + "class": "table", + "hall": "Hall 1", + "x1": 33, + "y1": 0, + "x2": 25, + "y2": 37, + "width": 38, + "height": 8 + }, + { + "name": "d73-a.event.dreamhack.local", + "horizontal": 0, + "class": "switch", + "hall": "Hall 1", + "x1": 72, + "y1": 23, + "x2": 72, + "y2": 23, + "width": 5, + "height": 5 + }, + { + "name": "d74-a.event.dreamhack.local", + "horizontal": 0, + "class": "switch", + "hall": "Hall 1", + "x1": 55, + "y1": 23, + "x2": 55, + "y2": 23, + "width": 5, + "height": 5 + }, + { + "name": "d75-a.event.dreamhack.local", + "horizontal": 0, + "class": "switch", + "hall": "Hall 1", + "x1": 38, + "y1": 23, + "x2": 38, + "y2": 23, + "width": 5, + "height": 5 + } + ], + "Dist": [ + { + "name": "dist-core.event.dreamhack.local", + "horizontal": 0, + "class": "switch", + "hall": "Dist", + "x1": 0, + "y1": 0, + "x2": 0, + "y2": 0, + "width": 0, + "height": 0 + } + ], + "Prod": [ + { + "name": "prod-sw1.event.dreamhack.se", + "horizontal": 0, + "class": "switch", + "hall": "Prod", + "x1": 0, + "y1": 0, + "x2": 0, + "y2": 0, + "width": 0, + "height": 0 + } + ], + "Grid": [ + { + "name": "Hall 1", + "horizontal": 0, + "class": "hall", + "hall": "Grid", + "x1": 0, + "y1": 0, + "x2": 0, + "y2": 0, + "width": 0, + "height": 0 + } + ] } -] diff --git a/src/examples/example.js b/src/examples/example.js index 5ec3c34..0c61bd1 100644 --- a/src/examples/example.js +++ b/src/examples/example.js @@ -1,13 +1,19 @@ -var map = null; - +// Minimal dhmap usage: load a map, draw it, then push switch statuses. +// +// The status values are the keys of dhmap.colour ('OK', 'CRITICAL', +// 'WARNING', 'SPEED', 'STP', 'ERRORS', 'ALERT'); anything unrecognised - or a +// switch you omit - renders as UNKNOWN. $.getJSON('./data.json', function(objects) { - dhmap.init(objects); -}); - -setTimeout(function() { - dhmap.updateSwitches({ - "d73-a.event.dreamhack.local": true, - "d74-a.event.dreamhack.local": false, - "d75-a.event.dreamhack.local": true + dhmap.init(objects, function(object) { + console.log('clicked', object.name); }); -}, 2000); + + // Statuses normally arrive from /analytics/; here we just fake one update. + setTimeout(function() { + dhmap.updateSwitches({ + 'd73-a.event.dreamhack.local': 'OK', + 'd74-a.event.dreamhack.local': 'CRITICAL', + 'd75-a.event.dreamhack.local': 'WARNING' + }); + }, 2000); +}); diff --git a/src/examples/index.html b/src/examples/index.html index f7e600b..d83bb62 100644 --- a/src/examples/index.html +++ b/src/examples/index.html @@ -3,9 +3,31 @@