From 0d265e96a87f574a68631e880a4a4e6afba1645d Mon Sep 17 00:00:00 2001 From: Gorka Date: Tue, 19 May 2026 11:12:10 -0300 Subject: [PATCH 1/8] feat: 5-section single-page reshape against network-dashboard-platform v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the v0.2.10 multi-page Map/Councils/Council-detail/Transactions architecture with a single-page hero layout per the locked sketch: §1 Counter strip (6 tiles) §2 Topology + Activity feed (side-by-side, click council to expand §3) §3 Council details panel (collapsed until topology click) §4 Sparklines (60m) + Asset breakdown (24h) side-by-side §5 World map WebSocket-driven (no HTTP polling). Wire subprotocol bumped to moonlight.network.v2 in step with the backend. World-map SVG + jurisdiction-by-geo logic carried forward from v0.2.10. Drops obsolete modules: router, nav, stellar lib, version-check, plus the four removed multi-page views. CSP simplified — public anonymous dashboard, no auth boundary, 'unsafe-inline' on style-src is acceptable. --- deno.json | 8 +- deno.lock | 330 +----------------- public/config.js | 3 +- src/app-styles.css | 637 ++++++++++++++++++++++++++++------ src/app.ts | 140 ++++++-- src/build.ts | 59 +--- src/lib/config.ts | 111 +----- src/lib/config_deploy_test.ts | 95 ----- src/lib/config_test.ts | 11 - src/lib/format.ts | 60 ++++ src/lib/nav.ts | 20 -- src/lib/network-events.ts | 118 +++++++ src/lib/router.ts | 101 ------ src/lib/router_test.ts | 57 --- src/lib/stellar.ts | 324 ----------------- src/lib/stellar_test.ts | 70 ---- src/lib/version-check.ts | 87 ----- src/lib/ws-client.ts | 145 ++++++++ src/lib/ws-client_test.ts | 36 ++ src/server.ts | 19 +- src/views/activity-feed.ts | 201 +++++++++++ src/views/asset-breakdown.ts | 73 ++++ src/views/council-detail.ts | 274 --------------- src/views/council-details.ts | 223 ++++++++++++ src/views/councils.ts | 208 ----------- src/views/counter-strip.ts | 93 +++++ src/views/map.ts | 156 --------- src/views/sparklines.ts | 170 +++++++++ src/views/topology.ts | 348 +++++++++++++++++++ src/views/transactions.ts | 203 ----------- src/views/world-map.ts | 177 ++++++++++ 31 files changed, 2317 insertions(+), 2240 deletions(-) delete mode 100644 src/lib/config_deploy_test.ts delete mode 100644 src/lib/config_test.ts create mode 100644 src/lib/format.ts delete mode 100644 src/lib/nav.ts create mode 100644 src/lib/network-events.ts delete mode 100644 src/lib/router.ts delete mode 100644 src/lib/router_test.ts delete mode 100644 src/lib/stellar.ts delete mode 100644 src/lib/stellar_test.ts delete mode 100644 src/lib/version-check.ts create mode 100644 src/lib/ws-client.ts create mode 100644 src/lib/ws-client_test.ts create mode 100644 src/views/activity-feed.ts create mode 100644 src/views/asset-breakdown.ts delete mode 100644 src/views/council-detail.ts create mode 100644 src/views/council-details.ts delete mode 100644 src/views/councils.ts create mode 100644 src/views/counter-strip.ts delete mode 100644 src/views/map.ts create mode 100644 src/views/sparklines.ts create mode 100644 src/views/topology.ts delete mode 100644 src/views/transactions.ts create mode 100644 src/views/world-map.ts diff --git a/deno.json b/deno.json index b27481a..a1aba03 100644 --- a/deno.json +++ b/deno.json @@ -1,12 +1,12 @@ { - "version": "0.2.10", + "version": "0.2.11", "license": "MIT", "tasks": { "dev": "deno run --allow-all --watch src/server.ts", "build": "deno run --allow-all src/build.ts", "serve": "deno run --allow-all src/server.ts", "test": "deno test --allow-all src/", - "check": "deno check src/server.ts src/build.ts src/app.ts src/lib/*.ts src/views/*.ts src/components/*.ts", + "check": "deno check src/server.ts src/build.ts src/app.ts src/lib/*.ts src/views/*.ts", "lint": "deno lint src/", "fmt": "deno fmt src/", "fmt:check": "deno fmt --check src/" @@ -17,8 +17,6 @@ }, "imports": { "@std/path": "jsr:@std/path@^1.0.0", - "@std/assert": "jsr:@std/assert@^1.0.0", - "stellar-sdk": "npm:@stellar/stellar-sdk@^15.0.1", - "@moonlight/ui/nav": "https://raw.githubusercontent.com/Moonlight-Protocol/ui/v0.3.1/src/nav/mod.ts" + "@std/assert": "jsr:@std/assert@^1.0.0" } } diff --git a/deno.lock b/deno.lock index 696d64f..6b24ca9 100644 --- a/deno.lock +++ b/deno.lock @@ -9,8 +9,7 @@ "jsr:@std/json@~0.213.1": "0.213.1", "jsr:@std/jsonc@0.213": "0.213.1", "jsr:@std/path@0.213": "0.213.1", - "jsr:@std/path@1": "1.1.4", - "npm:@stellar/stellar-sdk@^15.0.1": "15.0.1" + "jsr:@std/path@1": "1.1.4" }, "jsr": { "@luca/esbuild-deno-loader@0.10.3": { @@ -59,330 +58,6 @@ ] } }, - "npm": { - "@noble/curves@1.9.7": { - "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", - "dependencies": [ - "@noble/hashes" - ] - }, - "@noble/hashes@1.8.0": { - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==" - }, - "@stellar/js-xdr@4.0.0": { - "integrity": "sha512-+NmNa7Tk5BI5XFdy/6xGTqAN4J9a9KgCrCGhj2uEUTCBhLkch0M+QbKzNH8zEnejWe0p8w+0q5hUVX6L3OzoVA==" - }, - "@stellar/stellar-base@15.0.0": { - "integrity": "sha512-XQhxUr9BYiEcFcgc4oWcCMR9QJCny/GmmGsuwPKf/ieIcOeb5149KLHYx9mJCA0ea8QbucR2/GzV58QbXOTxQA==", - "dependencies": [ - "@noble/curves", - "@stellar/js-xdr", - "base32.js", - "bignumber.js", - "buffer", - "sha.js" - ] - }, - "@stellar/stellar-sdk@15.0.1": { - "integrity": "sha512-iZjWKXtfohsPh+CX9wRyQNIlXLeA9VyuQB6UMC7AFBD9XnR92eOjnlfeONzk/Bsrkk6+UPlpzSy2MuF+ydHP1A==", - "dependencies": [ - "@stellar/stellar-base", - "axios", - "bignumber.js", - "commander", - "eventsource", - "feaxios", - "randombytes", - "toml", - "urijs" - ], - "bin": true - }, - "asynckit@0.4.0": { - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, - "available-typed-arrays@1.0.7": { - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dependencies": [ - "possible-typed-array-names" - ] - }, - "axios@1.14.0": { - "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", - "dependencies": [ - "follow-redirects", - "form-data", - "proxy-from-env" - ] - }, - "base32.js@0.1.0": { - "integrity": "sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==" - }, - "base64-js@1.5.1": { - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" - }, - "bignumber.js@9.3.1": { - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==" - }, - "buffer@6.0.3": { - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dependencies": [ - "base64-js", - "ieee754" - ] - }, - "call-bind-apply-helpers@1.0.2": { - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dependencies": [ - "es-errors", - "function-bind" - ] - }, - "call-bind@1.0.9": { - "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", - "dependencies": [ - "call-bind-apply-helpers", - "es-define-property", - "get-intrinsic", - "set-function-length" - ] - }, - "call-bound@1.0.4": { - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dependencies": [ - "call-bind-apply-helpers", - "get-intrinsic" - ] - }, - "combined-stream@1.0.8": { - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dependencies": [ - "delayed-stream" - ] - }, - "commander@14.0.3": { - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==" - }, - "define-data-property@1.1.4": { - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dependencies": [ - "es-define-property", - "es-errors", - "gopd" - ] - }, - "delayed-stream@1.0.0": { - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" - }, - "dunder-proto@1.0.1": { - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dependencies": [ - "call-bind-apply-helpers", - "es-errors", - "gopd" - ] - }, - "es-define-property@1.0.1": { - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" - }, - "es-errors@1.3.0": { - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" - }, - "es-object-atoms@1.1.1": { - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dependencies": [ - "es-errors" - ] - }, - "es-set-tostringtag@2.1.0": { - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dependencies": [ - "es-errors", - "get-intrinsic", - "has-tostringtag", - "hasown" - ] - }, - "eventsource@2.0.2": { - "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==" - }, - "feaxios@0.0.23": { - "integrity": "sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==", - "dependencies": [ - "is-retry-allowed" - ] - }, - "follow-redirects@1.16.0": { - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==" - }, - "for-each@0.3.5": { - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dependencies": [ - "is-callable" - ] - }, - "form-data@4.0.5": { - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dependencies": [ - "asynckit", - "combined-stream", - "es-set-tostringtag", - "hasown", - "mime-types" - ] - }, - "function-bind@1.1.2": { - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" - }, - "get-intrinsic@1.3.0": { - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dependencies": [ - "call-bind-apply-helpers", - "es-define-property", - "es-errors", - "es-object-atoms", - "function-bind", - "get-proto", - "gopd", - "has-symbols", - "hasown", - "math-intrinsics" - ] - }, - "get-proto@1.0.1": { - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dependencies": [ - "dunder-proto", - "es-object-atoms" - ] - }, - "gopd@1.2.0": { - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" - }, - "has-property-descriptors@1.0.2": { - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dependencies": [ - "es-define-property" - ] - }, - "has-symbols@1.1.0": { - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" - }, - "has-tostringtag@1.0.2": { - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dependencies": [ - "has-symbols" - ] - }, - "hasown@2.0.3": { - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", - "dependencies": [ - "function-bind" - ] - }, - "ieee754@1.2.1": { - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" - }, - "inherits@2.0.4": { - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "is-callable@1.2.7": { - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==" - }, - "is-retry-allowed@3.0.0": { - "integrity": "sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A==" - }, - "is-typed-array@1.1.15": { - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "dependencies": [ - "which-typed-array" - ] - }, - "isarray@2.0.5": { - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" - }, - "math-intrinsics@1.1.0": { - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" - }, - "mime-db@1.52.0": { - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" - }, - "mime-types@2.1.35": { - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": [ - "mime-db" - ] - }, - "possible-typed-array-names@1.1.0": { - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==" - }, - "proxy-from-env@2.1.0": { - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==" - }, - "randombytes@2.1.0": { - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dependencies": [ - "safe-buffer" - ] - }, - "safe-buffer@5.2.1": { - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" - }, - "set-function-length@1.2.2": { - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dependencies": [ - "define-data-property", - "es-errors", - "function-bind", - "get-intrinsic", - "gopd", - "has-property-descriptors" - ] - }, - "sha.js@2.4.12": { - "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", - "dependencies": [ - "inherits", - "safe-buffer", - "to-buffer" - ], - "bin": true - }, - "to-buffer@1.2.2": { - "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", - "dependencies": [ - "isarray", - "safe-buffer", - "typed-array-buffer" - ] - }, - "toml@3.0.0": { - "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==" - }, - "typed-array-buffer@1.0.3": { - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "dependencies": [ - "call-bound", - "es-errors", - "is-typed-array" - ] - }, - "urijs@1.19.11": { - "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==" - }, - "which-typed-array@1.1.20": { - "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", - "dependencies": [ - "available-typed-arrays", - "call-bind", - "call-bound", - "for-each", - "get-proto", - "gopd", - "has-tostringtag" - ] - } - }, "remote": { "https://deno.land/x/denoflate@1.2.1/mod.ts": "f5628e44b80b3d80ed525afa2ba0f12408e3849db817d47a883b801f9ce69dd6", "https://deno.land/x/denoflate@1.2.1/pkg/denoflate.js": "b9f9ad9457d3f12f28b1fb35c555f57443427f74decb403113d67364e4f2caf4", @@ -393,8 +68,7 @@ "workspace": { "dependencies": [ "jsr:@std/assert@1", - "jsr:@std/path@1", - "npm:@stellar/stellar-sdk@^15.0.1" + "jsr:@std/path@1" ] } } diff --git a/public/config.js b/public/config.js index da784ba..6d2b35f 100644 --- a/public/config.js +++ b/public/config.js @@ -1,6 +1,5 @@ globalThis.__DASHBOARD_CONFIG__ = { environment: "development", stellarNetwork: "testnet", - rpcUrl: "https://soroban-testnet.stellar.org", - councilPlatformUrl: "http://localhost:3115", + networkDashboardPlatformUrl: "ws://localhost:3035", }; diff --git a/src/app-styles.css b/src/app-styles.css index 9b09f21..fa653b3 100644 --- a/src/app-styles.css +++ b/src/app-styles.css @@ -1,179 +1,596 @@ /* * App-specific styles for network-dashboard. * - * Tokens + base-styles + nav now come from @moonlight/ui at the pinned - * tag — fetched and concatenated into public/styles.css by src/build.ts. - * This file contains ONLY selectors the lib does not ship. - * (network-dashboard does not consume the lib's stepper or invite-waitlist.) + * The build pipeline concatenates @moonlight/ui's tokens + base-styles + * + nav onto this file at build time. Keep this file scoped to the + * dashboard's own 5-section layout. */ -/* Nav-brand hover (lib's nav.css doesn't ship this variant) */ -.nav-brand:hover { - opacity: 0.9; +:root { + --frame-stroke: #333; + --counter-blue: #1971c2; + --counter-blue-bg: #e7f5ff; + --council-green: #2f9e44; + --council-green-bg: #d3f9d8; + --pp-orange: #e8590c; + --pp-orange-bg: #ffe8cc; + --pulse-red: #f03e3e; + --moonlight-yellow: #fff3bf; + --sparkline-blue: #1c7ed6; + --sparkline-purple: #9775fa; + --sparkline-green: #2f9e44; + --activity-green: #d3f9d8; + --activity-gray: #f1f3f5; + --activity-yellow: #fff9db; + --activity-blue: #d0ebff; + --activity-orange: #ffe8cc; + --activity-purple: #e5dbff; + --activity-teal: #c5f6fa; } -/* Tables */ -table { - width: 100%; - border-collapse: collapse; - margin: 1rem 0; +body { + margin: 0; + font-family: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, + sans-serif; + color: #212529; + background: #f8f9fa; } -th, td { - padding: 0.75rem 1rem; - text-align: left; - border-bottom: 1px solid var(--border); +#app { + min-height: 100vh; + padding: 16px 20px 32px; + box-sizing: border-box; } -th { - color: var(--text-muted); - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.05em; +.dashboard { + display: flex; + flex-direction: column; + gap: 16px; + max-width: 1440px; + margin: 0 auto; } -/* Utility */ -.text-muted { - color: var(--text-muted); +.row { + display: grid; + gap: 16px; } -/* Loading + clickable row */ -.loading { - color: var(--text-muted); - padding: 2rem 0; +.row.hero { + grid-template-columns: minmax(0, 1fr) 300px; } -.clickable-row { - cursor: pointer; - transition: background 0.15s; +.row.trends { + grid-template-columns: minmax(0, 1fr) minmax(280px, 420px); } -.clickable-row:hover { - background: var(--surface); +.section { + background: #ffffff; + border: 1px solid var(--frame-stroke); + border-radius: 8px; + padding: 12px 16px; + box-sizing: border-box; } -/* Map */ -.map-container { - background: var(--surface); - border: 1px solid var(--border); - border-radius: 8px; - padding: 1rem; - margin: 1rem 0 2rem; - overflow: hidden; +.dashboard-footer { + text-align: center; + font-size: 12px; + color: #868e96; + padding: 8px 0 0; } -.world-map { +/* §1 — Counter strip ──────────────────────────────────────────────── */ + +.counter-strip { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 12px; + padding: 12px; +} + +.counter-tile { + background: var(--counter-blue-bg); + border: 1px solid var(--counter-blue); + border-radius: 6px; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.counter-label { + font-size: 11px; + font-weight: 600; + letter-spacing: 0.04em; + color: var(--counter-blue); + text-transform: uppercase; +} + +.counter-value { + font-size: 24px; + font-weight: 700; + color: #0b3d6e; + font-variant-numeric: tabular-nums; +} + +/* §2 — Topology ───────────────────────────────────────────────────── */ + +.topology { + display: flex; + flex-direction: column; + min-height: 540px; + padding: 8px; +} + +.topology-svg { width: 100%; - height: auto; + height: 100%; + min-height: 520px; } -.council-dot { - fill: var(--primary); - opacity: 0.8; - cursor: pointer; - transition: opacity 0.15s, r 0.15s; +.topology-label-center { + font-size: 14px; + font-weight: 700; + letter-spacing: 0.08em; + fill: #212529; } -.council-dot:hover { - opacity: 1; - fill: var(--primary-hover); +.topology-label-council { + font-size: 12px; + font-weight: 600; + fill: #1b4332; } -.council-label { - fill: var(--text-muted); +.topology-label-jurisdiction { font-size: 10px; - text-anchor: middle; + fill: #495057; +} + +.topology-hint { + font-size: 14px; + fill: #868e96; + font-style: italic; +} + +.topology-council circle { + transition: filter 120ms ease-out, stroke-width 120ms ease-out; +} + +.topology-council:hover circle { + filter: brightness(1.06); + stroke-width: 3; +} + +.topology-council.selected circle { + stroke: #1864ab; + stroke-width: 3.5; +} + +.topology-pulse { pointer-events: none; } -/* Council grid */ -.council-grid { +/* §2 — Activity feed ──────────────────────────────────────────────── */ + +.activity-feed { + display: flex; + flex-direction: column; + min-height: 540px; + padding: 12px; +} + +.activity-feed-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.activity-feed-title { + font-weight: 700; + font-size: 14px; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.activity-feed-status { + font-size: 11px; + padding: 2px 8px; + border-radius: 999px; + background: #f1f3f5; + color: #495057; +} + +.activity-feed-status[data-state="open"] { + background: #d3f9d8; + color: #1b4332; +} + +.activity-feed-status[data-state="connecting"] { + background: #fff3bf; + color: #5c3c00; +} + +.activity-feed-status[data-state="closed"] { + background: #ffe3e3; + color: #8a2a2a; +} + +.activity-feed-list { + flex: 1; + display: flex; + flex-direction: column; + gap: 8px; + overflow: hidden; +} + +.activity-card { + display: flex; + gap: 10px; + align-items: flex-start; + border-radius: 6px; + border: 1px solid rgba(0, 0, 0, 0.1); + padding: 8px 10px; + background: var(--activity-gray); + animation: card-in 240ms ease-out; + opacity: 1; + transition: opacity 600ms ease-in, transform 600ms ease-in; +} + +.activity-card.fading { + opacity: 0; + transform: translateY(-4px); +} + +@keyframes card-in { + from { + transform: translateY(-4px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.activity-card.kind-council_formed { + background: var(--activity-purple); +} +.activity-card.kind-provider_added { + background: var(--activity-green); +} +.activity-card.kind-provider_removed { + background: var(--activity-gray); +} +.activity-card.kind-asset_registered { + background: var(--activity-yellow); +} +.activity-card.kind-channel_settlement { + background: var(--activity-blue); +} +.activity-card.kind-channel_deposit { + background: var(--activity-orange); +} +.activity-card.kind-channel_bundle { + background: var(--activity-teal); +} + +.activity-glyph { + font-size: 18px; + line-height: 1.1; + font-weight: 700; + margin-top: 2px; +} + +.activity-body { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.activity-title { + font-size: 12px; + font-weight: 700; + color: #212529; +} + +.activity-council { + font-size: 12px; + color: #495057; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.activity-detail { + font-size: 11px; + color: #495057; + font-family: ui-monospace, "SF Mono", Menlo, monospace; +} + +/* §3 — Council details ────────────────────────────────────────────── */ + +.council-details { + padding: 14px 18px; +} + +.council-details-hint { + font-size: 13px; + color: #868e96; + font-style: italic; +} + +.council-details-hint.hidden, +.council-details-panel.hidden { + display: none; +} + +.council-details-header { + display: flex; + gap: 16px; + align-items: baseline; + margin-bottom: 10px; +} + +.council-details-name { + margin: 0; + font-size: 18px; + font-weight: 700; + color: #1b4332; +} + +.council-details-jurisdiction { + font-size: 12px; + color: #495057; + background: #f1f3f5; + padding: 2px 8px; + border-radius: 999px; +} + +.council-details-grid { display: grid; - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); - gap: 1rem; - margin-top: 1rem; + grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); + gap: 10px; + margin-bottom: 12px; } -.council-card { - background: var(--surface); - border: 1px solid var(--border); - border-radius: 8px; - padding: 1.25rem; - text-decoration: none; - color: var(--text); - transition: border-color 0.15s; +.kv-block { + display: flex; + flex-direction: column; + gap: 2px; + background: #f8f9fa; + border-radius: 6px; + padding: 8px 10px; + border: 1px solid #e9ecef; +} + +.kv-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #495057; +} + +.kv-value { + font-size: 17px; + font-weight: 700; + color: #212529; + font-variant-numeric: tabular-nums; } -.council-card:hover { - border-color: var(--primary); +.kv-sub { + font-size: 11px; + color: #868e96; +} + +.council-details-subheader { + margin: 12px 0 6px; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #495057; } -.council-card-header { +.council-details-pp-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 4px; + font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 11px; + color: #212529; +} + +.council-details-pp-list li { + background: #f8f9fa; + border-radius: 4px; + padding: 4px 8px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* §4 — Sparklines ─────────────────────────────────────────────────── */ + +.sparklines { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.sparkline-card { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px 10px; + border: 1px solid #dee2e6; + border-radius: 6px; + background: #ffffff; +} + +.sparkline-header { display: flex; justify-content: space-between; - align-items: center; - margin-bottom: 0.5rem; + align-items: baseline; } -.council-name { +.sparkline-title { + font-size: 11px; font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #495057; } -.council-card-meta { - font-size: 0.875rem; - color: var(--text-muted); - margin-bottom: 0.25rem; +.sparkline-value { + font-size: 16px; + font-weight: 700; + color: #212529; + font-variant-numeric: tabular-nums; } -.council-card-id { - font-size: 0.7rem; - color: var(--text-muted); +.sparkline-svg { + width: 100%; + height: 80px; +} + +.sparkline-unit { + font-size: 10px; + color: #868e96; + align-self: flex-end; } -/* Transaction feed */ -.feed-list { +/* §4 — Asset breakdown ────────────────────────────────────────────── */ + +.asset-breakdown { display: flex; flex-direction: column; - gap: 0.5rem; - margin-top: 1rem; + gap: 8px; } -.feed-item { - background: var(--surface); - border: 1px solid var(--border); - border-radius: 8px; - padding: 0.75rem 1rem; +.asset-breakdown-header { + margin-bottom: 4px; } -.feed-item-header { +.asset-breakdown-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #495057; +} + +.asset-breakdown-list { display: flex; + flex-direction: column; + gap: 6px; +} + +.asset-breakdown-empty { + font-size: 12px; + color: #868e96; + font-style: italic; +} + +.asset-row { + display: grid; + grid-template-columns: 60px minmax(0, 1fr) 56px 90px; + gap: 8px; align-items: center; - gap: 0.75rem; - margin-bottom: 0.25rem; + font-size: 12px; } -.feed-event-type { - font-weight: 500; - font-size: 0.875rem; +.asset-code { + font-weight: 700; + color: #212529; } -.feed-item-details { - font-size: 0.8rem; - margin-bottom: 0.25rem; +.asset-bar-box { + background: #f1f3f5; + border-radius: 4px; + height: 12px; + overflow: hidden; } -.feed-item-id { - font-size: 0.7rem; - color: var(--text-muted); +.asset-bar { + height: 100%; + background: linear-gradient(90deg, #2f9e44 0%, #82c91e 100%); + border-radius: 4px; + transition: width 220ms ease-out; } -/* Error banner */ -.error-banner { - background: rgba(239, 68, 68, 0.1); - border: 1px solid var(--inactive); - border-radius: 8px; - padding: 0.75rem 1rem; - font-size: 0.875rem; - color: var(--inactive); - margin-bottom: 1rem; +.asset-percent { + font-variant-numeric: tabular-nums; + color: #495057; + text-align: right; +} + +.asset-amount { + font-variant-numeric: tabular-nums; + text-align: right; + font-family: ui-monospace, "SF Mono", Menlo, monospace; +} + +/* §5 — World map ──────────────────────────────────────────────────── */ + +.world-map { + display: flex; + flex-direction: column; + gap: 8px; +} + +.world-map-header { + display: flex; + justify-content: space-between; + align-items: baseline; +} + +.world-map-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #495057; +} + +.world-map-status { + font-size: 11px; + color: #868e96; +} + +.world-map-host { + width: 100%; + height: 320px; +} + +.world-map-svg { + width: 100%; + height: 100%; +} + +.world-map-hint { + font-size: 14px; + fill: #adb5bd; +} + +@media (max-width: 960px) { + .row.hero, + .row.trends { + grid-template-columns: 1fr; + } + .counter-strip { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .sparklines { + grid-template-columns: 1fr; + } + .topology, + .activity-feed { + min-height: 360px; + } } diff --git a/src/app.ts b/src/app.ts index e00f669..9453096 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,34 +1,110 @@ -import { navigate, route, startRouter } from "./lib/router.ts"; -import { mapView } from "./views/map.ts"; -import { councilsView } from "./views/councils.ts"; -import { councilDetailView } from "./views/council-detail.ts"; -import { transactionsView } from "./views/transactions.ts"; - -route("/map", mapView); -route("/councils", councilsView); -route("/council/:id", councilDetailView); -route("/transactions", transactionsView); - -route("/", () => { - navigate("/map"); - return document.createElement("div"); -}); - -route("/404", () => { - const el = document.createElement("div"); - el.className = "login-container"; - el.innerHTML = - `

404

Page not found.

Back to dashboard
`; - return el; -}); - -startRouter(); - -// Dev-mode version check — __DEV_MODE__ is false in production, esbuild removes the block -import { checkVersions } from "./lib/version-check.ts"; -declare const __DEV_MODE__: boolean; -if (__DEV_MODE__) { - checkVersions().then((banner) => { - if (banner) document.body.prepend(banner); +/** + * Network-dashboard SPA entry. Single page, no router, no auth. + * + * Renders the 5-section layout per the locked design sketch and pipes + * frames from the public WebSocket into each section. + * + * §1 Counter strip + * §2 Topology + Activity feed (side-by-side) + * §3 Council details panel (collapsed until topology click) + * §4 Sparklines + Asset breakdown (side-by-side) + * §5 World map + */ +import { NETWORK_DASHBOARD_PLATFORM_URL } from "./lib/config.ts"; +import { connectNetworkPlatform } from "./lib/ws-client.ts"; +import { CounterStrip } from "./views/counter-strip.ts"; +import { Topology } from "./views/topology.ts"; +import { ActivityFeed } from "./views/activity-feed.ts"; +import { CouncilDetails } from "./views/council-details.ts"; +import { SparklineGroup } from "./views/sparklines.ts"; +import { AssetBreakdown } from "./views/asset-breakdown.ts"; +import { WorldMap } from "./views/world-map.ts"; + +declare const __APP_VERSION__: string; + +function renderShell(): { + counters: CounterStrip; + topology: Topology; + feed: ActivityFeed; + details: CouncilDetails; + sparklines: SparklineGroup; + assets: AssetBreakdown; + worldMap: WorldMap; +} { + const app = document.getElementById("app"); + if (!app) throw new Error("#app root not found"); + app.textContent = ""; + + const layout = document.createElement("div"); + layout.className = "dashboard"; + + const counters = new CounterStrip(); + + const heroRow = document.createElement("div"); + heroRow.className = "row hero"; + const topology = new Topology(); + const feed = new ActivityFeed(); + heroRow.append(topology.element(), feed.element()); + + const details = new CouncilDetails(); + + const trendsRow = document.createElement("div"); + trendsRow.className = "row trends"; + const sparklines = new SparklineGroup(); + const assets = new AssetBreakdown(); + trendsRow.append(sparklines.element(), assets.element()); + + const worldMap = new WorldMap(); + + layout.append( + counters.element(), + heroRow, + details.element(), + trendsRow, + worldMap.element(), + ); + + const footer = document.createElement("footer"); + footer.className = "dashboard-footer"; + footer.textContent = `Moonlight Network Dashboard · v${__APP_VERSION__}`; + layout.appendChild(footer); + + app.appendChild(layout); + return { counters, topology, feed, details, sparklines, assets, worldMap }; +} + +function bootstrap() { + const { counters, topology, feed, details, sparklines, assets, worldMap } = + renderShell(); + + topology.setCouncilClickHandler((councilId) => { + details.select(councilId); + topology.setSelectedCouncil(councilId); + }); + + if (!NETWORK_DASHBOARD_PLATFORM_URL) { + feed.setStatus("closed"); + return; + } + + connectNetworkPlatform(NETWORK_DASHBOARD_PLATFORM_URL, { + onStatusChange: (status) => feed.setStatus(status), + onSnapshot: (frame) => { + counters.render(frame.counters); + topology.render(frame.topology); + feed.seed(frame.recent); + details.setTopology(frame.topology); + details.setRollingMetrics(frame.councilRolling); + sparklines.render(frame.sparklines); + assets.render(frame.assetBreakdown); + worldMap.render(frame.topology); + }, + onEvent: (event) => { + feed.append(event); + topology.pulse(event); + counters.bumpFromLiveEvent(); + }, }); } + +bootstrap(); diff --git a/src/build.ts b/src/build.ts index 0b0183d..6a43c27 100644 --- a/src/build.ts +++ b/src/build.ts @@ -1,23 +1,16 @@ /** - * Bundles src/app.ts into public/app.js for the browser. - * Uses esbuild via Deno with denoPlugins for import map resolution. + * Bundles src/app.ts into public/app.js and emits the auxiliary build + * artifacts the static server hosts: + * + * - public/app.js — bundled SPA + * - public/styles.css — the single app stylesheet (no upstream concat) + * - public/health.json — used by /health probes in deploy-verify */ // deno-lint-ignore no-import-prefix -- build script intentionally pins the URL import * as esbuild from "https://deno.land/x/esbuild@v0.20.1/mod.js"; // deno-lint-ignore no-import-prefix -- build script intentionally pins the version import { denoPlugins } from "jsr:@luca/esbuild-deno-loader@0.10"; -// Pinned @moonlight/ui tag. raw.githubusercontent.com serves CSS as -// text/plain with nosniff so browsers refuse @import of these URLs; we -// fetch + concatenate at build time and write the result to public/styles.css. -// Do not change without bumping the consumer-side deps explicitly. -const UI_LIB_TAG = "v0.3.1"; -const UI_LIB_CSS_FILES = [ - "tokens/tokens.css", - "base-styles/base-styles.css", - "nav/nav.css", -]; - async function writeHealthJson(version: string): Promise { const health = { status: "ok", service: "network-dashboard", version }; await Deno.writeTextFile("public/health.json", JSON.stringify(health) + "\n"); @@ -25,25 +18,12 @@ async function writeHealthJson(version: string): Promise { } async function buildStyles(): Promise { - const parts: string[] = []; - for (const path of UI_LIB_CSS_FILES) { - const url = - `https://raw.githubusercontent.com/Moonlight-Protocol/ui/${UI_LIB_TAG}/src/${path}`; - const res = await fetch(url); - if (!res.ok) { - throw new Error( - `Failed to fetch ${url}: ${res.status} ${res.statusText}`, - ); - } - const body = await res.text(); - parts.push(`/* @moonlight/ui ${UI_LIB_TAG} — ${path} */\n${body}`); - } const appStyles = await Deno.readTextFile("src/app-styles.css"); - parts.push(`/* network-dashboard app-styles */\n${appStyles}`); - await Deno.writeTextFile("public/styles.css", parts.join("\n")); - console.log( - `Built public/styles.css from @moonlight/ui@${UI_LIB_TAG} + src/app-styles.css`, + await Deno.writeTextFile( + "public/styles.css", + `/* network-dashboard app-styles */\n${appStyles}`, ); + console.log("Built public/styles.css from src/app-styles.css"); } const isProduction = Deno.args.includes("--production"); @@ -51,25 +31,8 @@ const denoJson = JSON.parse(await Deno.readTextFile("deno.json")); const version = denoJson.version ?? "0.0.0"; await writeHealthJson(version); - await buildStyles(); -async function resolveSorobanCoreVersion(): Promise { - try { - const res = await fetch( - "https://api.github.com/repos/Moonlight-Protocol/soroban-core/releases/latest", - ); - if (!res.ok) return "unknown"; - const release = await res.json(); - return ((release.tag_name as string) ?? "unknown").replace(/^v/, ""); - } catch { - return "unknown"; - } -} - -const sorobanCoreVersion = await resolveSorobanCoreVersion(); -console.log(`Resolved soroban-core version: ${sorobanCoreVersion}`); - await esbuild.build({ entryPoints: ["src/app.ts"], bundle: true, @@ -81,8 +44,6 @@ await esbuild.build({ sourcemap: !isProduction, define: { "__APP_VERSION__": JSON.stringify(version), - "__SOROBAN_CORE_VERSION__": JSON.stringify(sorobanCoreVersion), - "__DEV_MODE__": JSON.stringify(!isProduction), }, plugins: [...denoPlugins({ configPath: `${Deno.cwd()}/deno.json` })], }); diff --git a/src/lib/config.ts b/src/lib/config.ts index df36e5b..d46d012 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,26 +1,16 @@ /** - * Dashboard configuration. - * Reads from global config object set in index.html or defaults. + * Public-config surface for the network-dashboard SPA. + * + * Values are read from `globalThis.__DASHBOARD_CONFIG__` (set by + * `public/config.js`, which the static server includes ahead of the + * bundled SPA). Production envs ship a substituted `config.js`; local-dev + * uses the committed default that points at the loopback stack. */ -export interface ChannelConfig { - privacyChannelId: string; - assetCode: string; -} - -export interface CouncilConfig { - name: string; - channelAuthId: string; - channels: ChannelConfig[]; - jurisdictions: string[]; - website?: string; -} - interface DashboardConfig { environment?: string; stellarNetwork?: "testnet" | "mainnet" | "standalone"; - rpcUrl?: string; - councilPlatformUrl?: string; + networkDashboardPlatformUrl?: string; } declare global { @@ -29,8 +19,6 @@ declare global { } } -// Read config from globalThis to work in both browser and Deno test environments. -// window.__DASHBOARD_CONFIG__ is set by config.js which loads before app.js. const config: DashboardConfig | undefined = "__DASHBOARD_CONFIG__" in globalThis ? (globalThis as Record) .__DASHBOARD_CONFIG__ as DashboardConfig @@ -46,85 +34,12 @@ const c = config ?? {}; export const ENVIRONMENT = c.environment ?? "development"; export const IS_PRODUCTION = ENVIRONMENT === "production"; export const STELLAR_NETWORK = c.stellarNetwork ?? "testnet"; -export const RPC_URL = c.rpcUrl ?? "https://soroban-testnet.stellar.org"; -export const COUNCIL_PLATFORM_URL = c.councilPlatformUrl ?? ""; - -export function getNetworkPassphrase(): string { - switch (STELLAR_NETWORK) { - case "mainnet": - return "Public Global Stellar Network ; September 2015"; - case "standalone": - return "Standalone Network ; February 2017"; - default: - return "Test SDF Network ; September 2015"; - } -} - -interface PlatformCouncilEntry { - council?: { name?: string; channelAuthId?: string } | null; - jurisdictions?: { countryCode?: string }[]; - channels?: { channelContractId?: string; assetCode?: string }[]; -} - -function mapPlatformCouncils(entries: PlatformCouncilEntry[]): CouncilConfig[] { - return entries - .filter(( - e, - ): e is PlatformCouncilEntry & { council: { channelAuthId: string } } => - !!e.council?.channelAuthId - ) - .map((e) => ({ - name: e.council.name ?? "Unnamed council", - channelAuthId: e.council.channelAuthId, - jurisdictions: (e.jurisdictions ?? []) - .map((j) => j.countryCode) - .filter((code): code is string => !!code), - channels: (e.channels ?? []) - .filter((ch): ch is { channelContractId: string; assetCode?: string } => - !!ch.channelContractId - ) - .map((ch) => ({ - privacyChannelId: ch.channelContractId, - assetCode: ch.assetCode ?? "", - })), - })); -} - -let councilsCache: Promise | null = null; /** - * Returns the list of councils registered with council-platform. - * Cached for the lifetime of the page — refresh the tab to repoll. - * Returns an empty array if COUNCIL_PLATFORM_URL is not configured or the - * fetch fails; views fall back to their own empty-state UI in that case. + * Base URL for the network-dashboard-platform WebSocket. The SPA appends + * `/api/v1/network/ws` to form the final URL. Empty means "no WS configured" + * — the SPA paints an idle layout with a connection-status banner instead + * of throwing. */ -export function getCouncils(): Promise { - if (councilsCache) return councilsCache; - - if (!COUNCIL_PLATFORM_URL) { - console.warn( - "councilPlatformUrl not configured — council list will be empty.", - ); - councilsCache = Promise.resolve([]); - return councilsCache; - } - - const url = `${ - COUNCIL_PLATFORM_URL.replace(/\/+$/, "") - }/api/v1/public/councils`; - councilsCache = fetch(url) - .then(async (res) => { - if (!res.ok) { - throw new Error(`council-platform returned HTTP ${res.status}`); - } - const body = await res.json(); - const data = Array.isArray(body?.data) ? body.data : []; - return mapPlatformCouncils(data); - }) - .catch((err) => { - console.warn("Failed to load councils from council-platform:", err); - return []; - }); - - return councilsCache; -} +export const NETWORK_DASHBOARD_PLATFORM_URL: string = + (c.networkDashboardPlatformUrl ?? "").trim(); diff --git a/src/lib/config_deploy_test.ts b/src/lib/config_deploy_test.ts deleted file mode 100644 index 65f0a64..0000000 --- a/src/lib/config_deploy_test.ts +++ /dev/null @@ -1,95 +0,0 @@ -/** - * Invariant test: every key declared on `DashboardConfig` must be written - * by the deploy workflow — unless it appears in an explicit "default-only" - * allowlist below. Catches the orphan-read class of bug, where source reads - * `config.X` but the deploy workflow never writes X (so the feature is - * silently dead in production). The bug class that motivated this test - * (moonlight-pay PR #27): `adminWallets` had been read by `isAdmin()` since - * the admin route shipped, but the deploy.yml never wrote it, so the admin - * route was unreachable on every deploy. - * - * This repo's deploy.yml uses `deno eval` (not a heredoc) to emit - * public/config.js, but the regex contract is the same: between the - * `Generate {production,mainnet} config` marker and the `Build production - * bundle` marker, every line of the form `:` is treated as a written - * config key. - */ -import { assertEquals } from "@std/assert"; - -const HERE = new URL(".", import.meta.url); -const CONFIG_TS = await Deno.readTextFile(new URL("./config.ts", HERE)); -const DEPLOY_YML = await Deno.readTextFile( - new URL("../../.github/workflows/deploy.yml", HERE), -); - -// Keys declared on `DashboardConfig` that may legitimately be omitted from -// a deploy heredoc because the source-side default is correct for that -// network. Adding a key here is a deliberate "yes, the default is right" -// decision — the test will fail if you add a new config key and forget to -// either wire it through deploy.yml or list it here. -const DEFAULT_OK: Record<"testnet" | "mainnet", Set> = { - testnet: new Set(), - mainnet: new Set(), -}; - -function extractConfigKeys(src: string): Set { - const m = src.match(/interface DashboardConfig\s*\{([\s\S]*?)\}/); - if (!m) { - throw new Error( - "Could not locate `interface DashboardConfig` in config.ts", - ); - } - return new Set( - [...m[1].matchAll(/^\s*(\w+)\??:/gm)].map((x) => x[1]), - ); -} - -function extractHeredocKeys(yml: string, header: string): Set { - const start = yml.indexOf(header); - if (start < 0) throw new Error(`Could not find heredoc header: ${header}`); - const end = yml.indexOf("Build production bundle", start); - if (end < 0) { - throw new Error(`Could not find end-of-heredoc after: ${header}`); - } - const block = yml.slice(start, end); - return new Set( - [...block.matchAll(/^\s*(\w+):/gm)] - .map((x) => x[1]) - // Strip YAML step keys ("name", "run") that aren't config keys. - .filter((k) => k !== "name" && k !== "run"), - ); -} - -Deno.test("deploy.yml writes every DashboardConfig key on testnet (or it's in DEFAULT_OK)", () => { - const declared = extractConfigKeys(CONFIG_TS); - const written = extractHeredocKeys(DEPLOY_YML, "Generate production config"); - const missing = [...declared].filter( - (k) => !written.has(k) && !DEFAULT_OK.testnet.has(k), - ); - assertEquals( - missing, - [], - `DashboardConfig keys read by source but not written by testnet deploy: ${ - missing.join(", ") - }. ` + - `Wire them through deploy.yml, or — only if the source-side default is intentionally correct ` + - `for testnet — add them to DEFAULT_OK.testnet in this file.`, - ); -}); - -Deno.test("deploy.yml writes every DashboardConfig key on mainnet (or it's in DEFAULT_OK)", () => { - const declared = extractConfigKeys(CONFIG_TS); - const written = extractHeredocKeys(DEPLOY_YML, "Generate mainnet config"); - const missing = [...declared].filter( - (k) => !written.has(k) && !DEFAULT_OK.mainnet.has(k), - ); - assertEquals( - missing, - [], - `DashboardConfig keys read by source but not written by mainnet deploy: ${ - missing.join(", ") - }. ` + - `Wire them through deploy.yml, or — only if the source-side default is intentionally correct ` + - `for mainnet — add them to DEFAULT_OK.mainnet in this file.`, - ); -}); diff --git a/src/lib/config_test.ts b/src/lib/config_test.ts deleted file mode 100644 index 9799037..0000000 --- a/src/lib/config_test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { assertEquals } from "@std/assert"; -import { getNetworkPassphrase } from "./config.ts"; - -// getNetworkPassphrase reads module-level STELLAR_NETWORK which defaults to -// "testnet" when no config is present (Deno test environment). -Deno.test("getNetworkPassphrase returns testnet passphrase by default", () => { - assertEquals( - getNetworkPassphrase(), - "Test SDF Network ; September 2015", - ); -}); diff --git a/src/lib/format.ts b/src/lib/format.ts new file mode 100644 index 0000000..360a7be --- /dev/null +++ b/src/lib/format.ts @@ -0,0 +1,60 @@ +/** + * Display formatters shared across the 5-section layout. Kept separate + * from `dom.ts` (which holds DOM helpers like truncateAddress) so the + * presentation logic stays grouped. + */ + +import { formatAmount } from "./dom.ts"; + +/** + * Compact formatter for the counter strip (1.2K, 38.4M). For sub-thousand + * values it returns the plain number; counters are always integers. + */ +export function formatCount(n: number): string { + if (!Number.isFinite(n)) return "—"; + if (Math.abs(n) < 1_000) return Math.round(n).toLocaleString(); + if (Math.abs(n) < 1_000_000) return `${(n / 1_000).toFixed(1)}K`; + if (Math.abs(n) < 1_000_000_000) return `${(n / 1_000_000).toFixed(1)}M`; + return `${(n / 1_000_000_000).toFixed(1)}B`; +} + +/** + * Latency tile copy. Backend reports null while no live samples are in + * window — we surface that as "—" rather than a misleading 0. + */ +export function formatLatencyMs(ms: number | null): string { + if (ms === null || !Number.isFinite(ms)) return "—"; + if (ms < 1_000) return `${Math.round(ms)} ms`; + return `${(ms / 1_000).toFixed(1)} s`; +} + +/** + * Whole-asset display from stroops (string-encoded for int64 safety). + * Two decimal places — sparkline and asset breakdown both render at this + * resolution. + */ +export function formatStroops(amountStroops: string): string { + return formatAmount(amountStroops); +} + +/** + * Format a percentage to one decimal place: 12.3%. + */ +export function formatPercent(pct: number): string { + if (!Number.isFinite(pct)) return "—"; + return `${pct.toFixed(1)}%`; +} + +/** + * "Deployed Nd ago" / "Nh ago" copy for the council details panel header. + * Falls back to the raw ISO if parsing fails. + */ +export function deployedAge(iso: string): string { + const ms = Date.parse(iso); + if (Number.isNaN(ms)) return iso; + const diffSec = Math.floor((Date.now() - ms) / 1_000); + if (diffSec < 60) return `${diffSec}s ago`; + if (diffSec < 3_600) return `${Math.floor(diffSec / 60)}m ago`; + if (diffSec < 86_400) return `${Math.floor(diffSec / 3_600)}h ago`; + return `${Math.floor(diffSec / 86_400)}d ago`; +} diff --git a/src/lib/nav.ts b/src/lib/nav.ts deleted file mode 100644 index 3ac5c4c..0000000 --- a/src/lib/nav.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { renderNav } from "@moonlight/ui/nav"; - -declare const __APP_VERSION__: string; - -/** - * Network-dashboard nav with the fixed brand + top-level links shared - * across every view. Each view calls this helper instead of the lib's - * renderNav directly to avoid duplicating the brand/links arrays. - */ -export function getNav(): HTMLElement { - return renderNav({ - brand: "Moonlight Network", - version: __APP_VERSION__, - links: [ - { href: "#/map", label: "Map" }, - { href: "#/councils", label: "Councils" }, - { href: "#/transactions", label: "Transactions" }, - ], - }); -} diff --git a/src/lib/network-events.ts b/src/lib/network-events.ts new file mode 100644 index 0000000..5035071 --- /dev/null +++ b/src/lib/network-events.ts @@ -0,0 +1,118 @@ +/** + * Mirror of the network-dashboard-platform's v2 frame shape. Kept here so + * the SPA can type-check incoming WS frames without depending on the + * backend's source tree. When the backend bumps NETWORK_WS_SUBPROTOCOL the + * SPA must update this file in lockstep. + */ + +export const NETWORK_EVENT_KINDS = [ + "council_formed", + "provider_added", + "provider_removed", + "asset_registered", + "channel_deposit", + "channel_settlement", + "channel_bundle", +] as const; + +export type NetworkEventKind = typeof NETWORK_EVENT_KINDS[number]; + +export type NetworkEvent = { + id: string; + kind: NetworkEventKind; + councilId: string; + councilName: string | null; + ledger: number; + occurredAt: string; + payload: Record; +}; + +export type CouncilTopologyEntry = { + id: string; + name: string | null; + providers: Array<{ publicKey: string; label: string | null }>; + channels: Array<{ + contractId: string; + assetCode: string; + assetContractId: string | null; + }>; + jurisdictions: string[]; +}; + +export type Counters = { + councils: number; + activePPs: number; + eventsLast24h: number; + assetsRegistered: number; + throughputPerMin: number; + latencyMs: number | null; +}; + +export type Sparklines = { + throughput: number[]; + latency: Array; + volume: number[]; +}; + +export type AssetBreakdownRow = { + assetContractId: string; + assetCode: string; + amountStroops: string; + percent: number; +}; + +export type CouncilRollingMetrics = { + bundlesLastHour: number; + eventsLastHour: number; + ratePerMin: number; + depositVolumeStroops: string; + settlementVolumeStroops: string; +}; + +export type SnapshotFrame = { + type: "snapshot"; + counters: Counters; + topology: CouncilTopologyEntry[]; + recent: NetworkEvent[]; + sparklines: Sparklines; + assetBreakdown: AssetBreakdownRow[]; + councilRolling: Record; + generatedAt: string; +}; + +export type LiveFrame = { + type: "event"; + event: NetworkEvent; +}; + +export type ServerFrame = SnapshotFrame | LiveFrame; + +export const NETWORK_WS_SUBPROTOCOL = "moonlight.network.v2"; + +/** + * Defensive narrowing of an untrusted JSON payload to a ServerFrame. + * Returns null if the frame doesn't smell right — the WS client treats + * that as a protocol-mismatch and logs without raising. + */ +export function parseServerFrame(value: unknown): ServerFrame | null { + if (typeof value !== "object" || value === null) return null; + const v = value as Record; + if (v.type === "snapshot") { + if ( + typeof v.counters !== "object" || v.counters === null || + !Array.isArray(v.topology) || + !Array.isArray(v.recent) || + typeof v.sparklines !== "object" || v.sparklines === null || + !Array.isArray(v.assetBreakdown) || + typeof v.councilRolling !== "object" || v.councilRolling === null + ) { + return null; + } + return value as SnapshotFrame; + } + if (v.type === "event") { + if (typeof v.event !== "object" || v.event === null) return null; + return value as LiveFrame; + } + return null; +} diff --git a/src/lib/router.ts b/src/lib/router.ts deleted file mode 100644 index b05b0c1..0000000 --- a/src/lib/router.ts +++ /dev/null @@ -1,101 +0,0 @@ -/** - * Minimal hash-based router for SPA navigation. - * Routes: #/map, #/councils, #/transactions, #/council/:id - */ -import { renderError } from "./dom.ts"; - -type RouteHandler = ( - params?: Record, -) => HTMLElement | Promise; - -const routes = new Map(); -let cleanups: (() => void)[] = []; - -export function route(path: string, handler: RouteHandler): void { - routes.set(path, handler); -} - -export function navigate(path: string, opts?: { force?: boolean }): void { - const current = globalThis.location.hash.replace(/^#/, ""); - if (opts?.force && current === path) { - render(); - } else { - globalThis.location.hash = path; - } -} - -function matchRoute( - path: string, -): { handler: RouteHandler; params: Record } | null { - // Exact match first - const exact = routes.get(path); - if (exact) return { handler: exact, params: {} }; - - // Parameterized match: /council/:id - for (const [pattern, handler] of routes) { - const patternParts = pattern.split("/"); - const pathParts = path.split("/"); - if (patternParts.length !== pathParts.length) continue; - - const params: Record = {}; - let match = true; - for (let i = 0; i < patternParts.length; i++) { - if (patternParts[i].startsWith(":")) { - params[patternParts[i].slice(1)] = pathParts[i]; - } else if (patternParts[i] !== pathParts[i]) { - match = false; - break; - } - } - if (match) return { handler, params }; - } - - return null; -} - -async function render(): Promise { - const hash = globalThis.location.hash || "#/"; - const path = hash.startsWith("#") - ? hash.slice(1).split("?")[0] - : hash.split("?")[0]; - - const matched = matchRoute(path) || - (routes.has("/404") ? { handler: routes.get("/404")!, params: {} } : null); - if (!matched) return; - - for (const fn of cleanups) { - fn(); - } - cleanups = []; - - const app = document.getElementById("app"); - if (!app) return; - - try { - const element = await matched.handler(matched.params); - app.innerHTML = ""; - app.appendChild(element); - } catch (error) { - console.warn("[router] View render failed:", error); - app.innerHTML = ""; - const container = document.createElement("main"); - container.className = "container"; - renderError( - container, - "Something went wrong", - "Failed to load this page. Please try again.", - ); - app.appendChild(container); - } - - globalThis.scrollTo(0, 0); -} - -export function startRouter(): void { - globalThis.addEventListener("hashchange", render); - render(); -} - -export function onCleanup(fn: () => void): void { - cleanups.push(fn); -} diff --git a/src/lib/router_test.ts b/src/lib/router_test.ts deleted file mode 100644 index c7579ef..0000000 --- a/src/lib/router_test.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { assertEquals } from "@std/assert"; - -// Test the route matching logic (extracted for testability) -function matchRoute( - path: string, - patterns: string[], -): { pattern: string; params: Record } | null { - // Exact match first - if (patterns.includes(path)) return { pattern: path, params: {} }; - - // Parameterized match - for (const pattern of patterns) { - const patternParts = pattern.split("/"); - const pathParts = path.split("/"); - if (patternParts.length !== pathParts.length) continue; - - const params: Record = {}; - let match = true; - for (let i = 0; i < patternParts.length; i++) { - if (patternParts[i].startsWith(":")) { - params[patternParts[i].slice(1)] = pathParts[i]; - } else if (patternParts[i] !== pathParts[i]) { - match = false; - break; - } - } - if (match) return { pattern, params }; - } - - return null; -} - -Deno.test("exact route match", () => { - const result = matchRoute("/councils", [ - "/map", - "/councils", - "/transactions", - ]); - assertEquals(result?.pattern, "/councils"); - assertEquals(result?.params, {}); -}); - -Deno.test("parameterized route match", () => { - const result = matchRoute("/council/ABC123", ["/map", "/council/:id"]); - assertEquals(result?.pattern, "/council/:id"); - assertEquals(result?.params, { id: "ABC123" }); -}); - -Deno.test("no match returns null", () => { - const result = matchRoute("/unknown", ["/map", "/councils"]); - assertEquals(result, null); -}); - -Deno.test("parameterized route does not match wrong segment count", () => { - const result = matchRoute("/council/ABC/extra", ["/council/:id"]); - assertEquals(result, null); -}); diff --git a/src/lib/stellar.ts b/src/lib/stellar.ts deleted file mode 100644 index 3b282ad..0000000 --- a/src/lib/stellar.ts +++ /dev/null @@ -1,324 +0,0 @@ -/** - * Read-only Stellar/Soroban helpers for querying contract state. - * No wallet, no signing — purely read operations. - */ -import { getNetworkPassphrase, RPC_URL } from "./config.ts"; - -const REQUEST_TIMEOUT_MS = 15_000; - -/** Errors encountered during the current view's queries. Cleared on each navigation. */ -export const queryErrors: { source: string; message: string; time: number }[] = - []; - -/** Generation counter to scope errors to the current view load. */ -let queryGeneration = 0; - -/** Clear accumulated query errors. Call at the start of each view load. */ -export function clearQueryErrors(): void { - queryErrors.length = 0; - queryGeneration++; -} - -function recordError(source: string, err: unknown, generation: number): void { - // Discard errors from a previous view's in-flight requests - if (generation !== queryGeneration) return; - const message = err instanceof Error ? err.message : String(err); - queryErrors.push({ source, message, time: Date.now() }); - if (queryErrors.length > 50) queryErrors.shift(); - console.warn(`[stellar:${source}]`, message); -} - -/** Wrap a promise with a timeout. */ -function withTimeout( - promise: Promise, - ms: number, - label: string, -): Promise { - return new Promise((resolve, reject) => { - const timer = setTimeout( - () => reject(new Error(`${label} timed out after ${ms}ms`)), - ms, - ); - promise.then( - (v) => { - clearTimeout(timer); - resolve(v); - }, - (e) => { - clearTimeout(timer); - reject(e); - }, - ); - }); -} - -// --- Stellar SDK types (subset used by this module) --- -// Note: hand-written to match @stellar/stellar-sdk@14.2.0. -// If the SDK API changes, these will need updating. - -interface StellarSdkSubset { - Contract: new (id: string) => StellarContract; - Account: new (publicKey: string, sequence: string) => StellarAccount; - TransactionBuilder: new ( - account: StellarAccount, - opts: { fee: string; networkPassphrase: string }, - ) => TxBuilder; - scValToNative(val: unknown): unknown; - rpc: { - Server: new (url: string, opts?: { allowHttp?: boolean }) => RpcServer; - }; -} - -interface StellarContract { - call(method: string, ...args: unknown[]): unknown; -} - -// StellarAccount — used as opaque type passed to TransactionBuilder. -type StellarAccount = Record; - -interface TxBuilder { - addOperation(op: unknown): TxBuilder; - setTimeout(seconds: number): TxBuilder; - build(): StellarTransaction; -} - -interface StellarTransaction { - toXDR(): string; -} - -interface SimulationResult { - error?: string; - result?: { retval?: unknown }; -} - -interface RpcServer { - simulateTransaction(tx: StellarTransaction): Promise; - getLatestLedger(): Promise<{ sequence: number }>; - getHealth(): Promise<{ - status: string; - latestLedger: number; - oldestLedger: number; - ledgerRetentionWindow: number; - }>; - getEvents(opts: EventsRequest): Promise; -} - -interface EventsRequest { - startLedger: number; - filters: { type: string; contractIds: string[] }[]; - limit: number; -} - -interface EventsResponse { - events?: RawEvent[]; -} - -interface RawEvent { - id: string; - contractId?: string; - ledger: number; - createdAt: string; - topic?: unknown[]; - value?: unknown; -} - -// --- SDK lazy-loading --- - -let stellarSdk: StellarSdkSubset | null = null; - -// Constant dummy public key for read-only simulations (avoids generating random keys). -const DUMMY_PK = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF"; - -async function sdk(): Promise { - if (!stellarSdk) { - // Runtime cast: we trust the SDK shape matches our interface for the - // methods we use. A version mismatch will surface as a runtime error - // on the first call, not silently. - const mod = await import("stellar-sdk"); - stellarSdk = mod as unknown as StellarSdkSubset; - } - return stellarSdk; -} - -async function getRpcServer(): Promise { - const s = await sdk(); - return new s.rpc.Server(RPC_URL, { - allowHttp: RPC_URL.startsWith("http://"), - }); -} - -// --- Public API --- - -/** - * Query a Privacy Channel contract for its supply. - */ -export async function getChannelSupply(contractId: string): Promise { - const gen = queryGeneration; - try { - const s = await sdk(); - const server = await getRpcServer(); - const contract = new s.Contract(contractId); - const result = await withTimeout( - simulateReadCall(server, s, contract, "supply"), - REQUEST_TIMEOUT_MS, - "getChannelSupply", - ); - if (typeof result === "bigint") return result; - if (typeof result === "number" && isFinite(result)) { - return BigInt(Math.trunc(result)); - } - if (typeof result === "string" && /^-?\d+$/.test(result)) { - return BigInt(result); - } - throw new Error(`Unexpected supply type: ${typeof result}`); - } catch (err) { - recordError(`getChannelSupply(${contractId.slice(0, 8)})`, err, gen); - return 0n; - } -} - -/** - * Simulate a read-only contract call (no signing needed). - */ -async function simulateReadCall( - server: RpcServer, - s: StellarSdkSubset, - contract: StellarContract, - method: string, -): Promise { - const account = new s.Account(DUMMY_PK, "0"); - const passphrase = getNetworkPassphrase(); - - const tx = new s.TransactionBuilder(account, { - fee: "100", - networkPassphrase: passphrase, - }) - .addOperation(contract.call(method)) - .setTimeout(30) - .build(); - - const sim = await server.simulateTransaction(tx); - if (sim.error) { - throw new Error(`Simulation failed: ${sim.error}`); - } - - if (sim.result?.retval) { - return s.scValToNative(sim.result.retval); - } - - return null; -} - -/** - * Get contract events from RPC. - * - * Note: RPC event retention is limited (~24h of ledgers). Events older than - * the retention window are not available. Provider counts derived from events - * may be incomplete. - */ -export async function getContractEvents( - contractId: string, - startLedger?: number, - limit = 100, -): Promise { - const gen = queryGeneration; - try { - const server = await getRpcServer(); - const s = await sdk(); - - const health = await withTimeout( - server.getHealth(), - REQUEST_TIMEOUT_MS, - "getHealth", - ); - const seq = health?.latestLedger; - const oldest = health?.oldestLedger; - if (typeof seq !== "number" || !isFinite(seq) || seq <= 0) { - throw new Error("Invalid latest ledger sequence"); - } - if (typeof oldest !== "number" || !isFinite(oldest) || oldest <= 0) { - throw new Error("Invalid oldest ledger sequence"); - } - const start = startLedger ?? Math.max(oldest, seq - 17280); - - const response = await withTimeout( - server.getEvents({ - startLedger: start, - filters: [{ type: "contract", contractIds: [contractId] }], - limit, - }), - REQUEST_TIMEOUT_MS, - "getEvents", - ); - - return (response.events ?? []).map((e: RawEvent) => ({ - id: typeof e.id === "string" ? e.id : String(e.id ?? ""), - type: parseEventType(e, s), - // SDK v15 may return contractId as an Address object — coerce to string - // so downstream string ops (truncateAddress, etc.) work. - contractId: typeof e.contractId === "string" - ? e.contractId - : (e.contractId != null ? String(e.contractId) : contractId), - ledger: e.ledger, - timestamp: e.createdAt, - topic: e.topic?.map((t: unknown) => safeScValToNative(s, t)) ?? [], - value: e.value ? safeScValToNative(s, e.value) : null, - })); - } catch (err) { - recordError(`getContractEvents(${contractId.slice(0, 8)})`, err, gen); - return []; - } -} - -/** - * Derive active provider list from a chronologically-ordered event stream. - * Processes events in order so add→remove→re-add correctly shows as active. - * Exported for testability. - */ -export function countProvidersFromEvents(events: ContractEvent[]): string[] { - const state = new Map(); // address → currently active - - for (const e of events) { - const addr = e.value != null ? String(e.value) : null; - if (!addr) continue; - if (e.type === "ProviderAdded") state.set(addr, true); - if (e.type === "ProviderRemoved") state.set(addr, false); - } - - return [...state.entries()].filter(([, active]) => active).map(([addr]) => - addr - ); -} - -function safeScValToNative(s: StellarSdkSubset, val: unknown): unknown { - try { - const result = s.scValToNative(val); - if (result !== null && typeof result === "object") { - return JSON.stringify(result); - } - return result; - } catch { - return null; - } -} - -function parseEventType(event: RawEvent, s: StellarSdkSubset): string { - if (!event.topic || event.topic.length === 0) return "unknown"; - try { - const first = s.scValToNative(event.topic[0]); - if (typeof first === "string") return first; - return "unknown"; - } catch { - return "unknown"; - } -} - -export interface ContractEvent { - id: string; - type: string; - contractId: string; - ledger: number; - timestamp: string; - topic: unknown[]; - value: unknown; -} diff --git a/src/lib/stellar_test.ts b/src/lib/stellar_test.ts deleted file mode 100644 index ffafd17..0000000 --- a/src/lib/stellar_test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { assertEquals } from "@std/assert"; -import { - clearQueryErrors, - countProvidersFromEvents, - queryErrors, -} from "./stellar.ts"; -import type { ContractEvent } from "./stellar.ts"; - -function mockEvent( - type: string, - value: string | null, - ledger = 1, -): ContractEvent { - return { - id: "e1", - type, - contractId: "C...", - ledger, - timestamp: "", - topic: [], - value, - }; -} - -Deno.test("queryErrors starts empty", () => { - assertEquals(Array.isArray(queryErrors), true); -}); - -Deno.test("clearQueryErrors empties the array", () => { - queryErrors.push({ source: "test", message: "err", time: 0 }); - clearQueryErrors(); - assertEquals(queryErrors.length, 0); -}); - -Deno.test("countProvidersFromEvents tracks adds and removes", () => { - const events = [ - mockEvent("ProviderAdded", "GAAA", 1), - mockEvent("ProviderAdded", "GBBB", 2), - mockEvent("ProviderRemoved", "GAAA", 3), - ]; - const result = countProvidersFromEvents(events); - assertEquals(result, ["GBBB"]); -}); - -Deno.test("countProvidersFromEvents handles add-remove-re-add", () => { - const events = [ - mockEvent("ProviderAdded", "GAAA", 1), - mockEvent("ProviderRemoved", "GAAA", 2), - mockEvent("ProviderAdded", "GAAA", 3), - ]; - const result = countProvidersFromEvents(events); - assertEquals(result, ["GAAA"]); -}); - -Deno.test("countProvidersFromEvents deduplicates multiple adds", () => { - const events = [ - mockEvent("ProviderAdded", "GAAA", 1), - mockEvent("ProviderAdded", "GAAA", 2), - ]; - assertEquals(countProvidersFromEvents(events).length, 1); -}); - -Deno.test("countProvidersFromEvents handles empty events", () => { - assertEquals(countProvidersFromEvents([]).length, 0); -}); - -Deno.test("countProvidersFromEvents ignores null values", () => { - const events = [mockEvent("ProviderAdded", null, 1)]; - assertEquals(countProvidersFromEvents(events).length, 0); -}); diff --git a/src/lib/version-check.ts b/src/lib/version-check.ts deleted file mode 100644 index 1cbd330..0000000 --- a/src/lib/version-check.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * Dev-mode version mismatch detection. - * Compares local versions against latest GitHub releases. - * Only runs in development; silently no-ops in production. - */ -declare const __APP_VERSION__: string; -declare const __SOROBAN_CORE_VERSION__: string; - -interface VersionEntry { - name: string; - local: string; - latest: string | null; -} - -async function fetchLatestRelease(repo: string): Promise { - try { - const res = await fetch( - `https://api.github.com/repos/Moonlight-Protocol/${repo}/releases/latest`, - ); - if (!res.ok) return null; - const data = await res.json(); - return (data.tag_name ?? "").replace(/^v/, ""); - } catch { - return null; - } -} - -function esc(s: string): string { - return s.replace(/&/g, "&").replace(//g, ">") - .replace(/"/g, """); -} - -function renderBanner(entries: VersionEntry[]): HTMLElement { - const banner = document.createElement("div"); - banner.className = "version-mismatch-banner"; - - const spans = entries.map((e) => { - let color: string; - let text: string; - if (!e.latest) { - color = "var(--pending)"; - text = `${esc(e.name)} v${esc(e.local)}`; - } else if (e.local === e.latest) { - color = "var(--active)"; - text = `${esc(e.name)} v${esc(e.local)}`; - } else { - color = "var(--inactive)"; - text = `${esc(e.name)} v${esc(e.local)} (latest: v${esc(e.latest)})`; - } - return `${text}`; - }); - - banner.innerHTML = spans.join( - ' · ', - ); - return banner; -} - -export async function checkVersions(): Promise { - try { - const entries: VersionEntry[] = []; - - const appLatest = await fetchLatestRelease("network-dashboard"); - entries.push({ - name: "network-dashboard", - local: __APP_VERSION__, - latest: appLatest, - }); - - const scVersion = typeof __SOROBAN_CORE_VERSION__ !== "undefined" - ? __SOROBAN_CORE_VERSION__ - : null; - if (scVersion && scVersion !== "unknown") { - const scLatest = await fetchLatestRelease("soroban-core"); - entries.push({ - name: "soroban-core", - local: scVersion, - latest: scLatest, - }); - } - - if (entries.length === 0) return null; - return renderBanner(entries); - } catch { - return null; - } -} diff --git a/src/lib/ws-client.ts b/src/lib/ws-client.ts new file mode 100644 index 0000000..25b97c1 --- /dev/null +++ b/src/lib/ws-client.ts @@ -0,0 +1,145 @@ +import { + NETWORK_WS_SUBPROTOCOL, + type NetworkEvent, + parseServerFrame, + type SnapshotFrame, +} from "./network-events.ts"; + +/** + * Persistent WebSocket client for the network-dashboard backend. + * + * - Auto-reconnects with exponential backoff (capped at 30s). + * - Exposes `onSnapshot` for the initial frame, `onEvent` per live frame, + * and `onStatusChange` for the UI banner ("connecting" / "open" / + * "closed"). + * - Status events fire on the next animation frame to avoid render + * loops; data events fire synchronously so the topology + feed update + * together. + */ + +export type WsStatus = "idle" | "connecting" | "open" | "closed"; + +export type WsHandlers = { + onSnapshot: (frame: SnapshotFrame) => void; + onEvent: (event: NetworkEvent) => void; + onStatusChange: (status: WsStatus) => void; +}; + +const INITIAL_BACKOFF_MS = 500; +const MAX_BACKOFF_MS = 30_000; + +/** + * Build the full WS URL from the configured base. The backend exposes its + * endpoint at `/api/v1/network/ws`; passing in just the host base keeps + * the config surface narrow. + */ +export function buildWsUrl(baseUrl: string): string | null { + const trimmed = baseUrl.trim(); + if (!trimmed) return null; + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return null; + } + if (parsed.protocol === "http:") parsed.protocol = "ws:"; + else if (parsed.protocol === "https:") parsed.protocol = "wss:"; + // Path may already be present in the configured base; append only when missing. + if (!parsed.pathname.endsWith("/network/ws")) { + parsed.pathname = `${ + parsed.pathname.replace(/\/+$/, "") + }/api/v1/network/ws`; + } + return parsed.toString(); +} + +export type WsHandle = { + close(): void; +}; + +export function connectNetworkPlatform( + baseUrl: string, + handlers: WsHandlers, +): WsHandle { + const url = buildWsUrl(baseUrl); + if (url === null) { + handlers.onStatusChange("closed"); + return { close() {} }; + } + + let socket: WebSocket | null = null; + let backoffMs = INITIAL_BACKOFF_MS; + let reconnectTimer: number | null = null; + let closed = false; + + const setStatus = (status: WsStatus) => { + handlers.onStatusChange(status); + }; + + const connect = () => { + if (closed) return; + setStatus("connecting"); + try { + socket = new WebSocket(url, NETWORK_WS_SUBPROTOCOL); + } catch (err) { + console.warn("WebSocket constructor failed", err); + scheduleReconnect(); + return; + } + socket.onopen = () => { + setStatus("open"); + backoffMs = INITIAL_BACKOFF_MS; + }; + socket.onmessage = (ev) => { + let parsed: unknown; + try { + parsed = JSON.parse(ev.data); + } catch { + return; + } + const frame = parseServerFrame(parsed); + if (frame === null) return; + if (frame.type === "snapshot") { + handlers.onSnapshot(frame); + } else { + handlers.onEvent(frame.event); + } + }; + socket.onclose = () => { + socket = null; + setStatus("closed"); + scheduleReconnect(); + }; + socket.onerror = () => { + // onclose follows; let it drive the reconnect path. + }; + }; + + const scheduleReconnect = () => { + if (closed) return; + if (reconnectTimer !== null) return; + const wait = backoffMs; + backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS); + reconnectTimer = globalThis.setTimeout(() => { + reconnectTimer = null; + connect(); + }, wait); + }; + + connect(); + + return { + close() { + closed = true; + if (reconnectTimer !== null) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + if (socket) { + socket.close(); + socket = null; + } + setStatus("closed"); + }, + }; +} diff --git a/src/lib/ws-client_test.ts b/src/lib/ws-client_test.ts new file mode 100644 index 0000000..03203f6 --- /dev/null +++ b/src/lib/ws-client_test.ts @@ -0,0 +1,36 @@ +import { assertEquals } from "@std/assert"; +import { buildWsUrl } from "./ws-client.ts"; + +Deno.test("buildWsUrl: http:// → ws://", () => { + assertEquals( + buildWsUrl("http://localhost:3035"), + "ws://localhost:3035/api/v1/network/ws", + ); +}); + +Deno.test("buildWsUrl: https:// → wss://", () => { + assertEquals( + buildWsUrl("https://dashboard-api.moonlightprotocol.io"), + "wss://dashboard-api.moonlightprotocol.io/api/v1/network/ws", + ); +}); + +Deno.test("buildWsUrl: ws:// preserved", () => { + assertEquals( + buildWsUrl("ws://localhost:3035"), + "ws://localhost:3035/api/v1/network/ws", + ); +}); + +Deno.test("buildWsUrl: already-suffixed paths kept as-is", () => { + assertEquals( + buildWsUrl("wss://example.com/network/ws"), + "wss://example.com/network/ws", + ); +}); + +Deno.test("buildWsUrl: empty/invalid returns null", () => { + assertEquals(buildWsUrl(""), null); + assertEquals(buildWsUrl(" "), null); + assertEquals(buildWsUrl("not-a-url"), null); +}); diff --git a/src/server.ts b/src/server.ts index 409345f..a6307c1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -18,18 +18,17 @@ const SECURITY_HEADERS: Record = { function getCSP(): string { const environment = Deno.env.get("ENVIRONMENT") || "development"; const isProd = environment === "production"; - // Non-prod runs against local services (council-platform, soroban-rpc, - // friendbot, etc. on assorted localhost ports) — use `*` so dev work - // isn't blocked by CSP. Production keeps the strict allow-list. + // Non-prod runs against local services (council-platform, network-dashboard- + // platform, etc. on assorted localhost ports) — use `*` so dev work isn't + // blocked. Production allows-list the dashboard-api WS hosts the SPA + // connects to; ws/wss schemes need explicit hosts even with 'self'. const connectSrc = isProd - ? "connect-src 'self' https://soroban-testnet.stellar.org https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/" + ? "connect-src 'self' wss://dashboard-api.moonlightprotocol.io wss://dashboard-api-testnet.moonlightprotocol.io" : "connect-src *"; - // The council-detail view renders inline `style="..."` attributes (progress - // bars, stat colours), which `style-src 'self'` rejects. Production keeps - // the strict policy; dev allows inline styles so the UI actually renders. - const styleSrc = isProd - ? "style-src 'self'" - : "style-src 'self' 'unsafe-inline'"; + // §4 asset-breakdown bars and a couple of inline transitions render via + // `style="..."`. The dashboard is public + anonymous, no auth boundary + // to defend, so 'unsafe-inline' on style-src is an acceptable trade. + const styleSrc = "style-src 'self' 'unsafe-inline'"; return [ "default-src 'self'", "script-src 'self'", diff --git a/src/views/activity-feed.ts b/src/views/activity-feed.ts new file mode 100644 index 0000000..8645577 --- /dev/null +++ b/src/views/activity-feed.ts @@ -0,0 +1,201 @@ +import type { NetworkEvent, NetworkEventKind } from "../lib/network-events.ts"; +import { truncateAddress } from "../lib/dom.ts"; +import { formatAmount } from "../lib/dom.ts"; +import type { WsStatus } from "../lib/ws-client.ts"; + +/** + * §2 (right) — Activity feed. Newest card on top; fade-out after 8s. + * Up to 5 visible. One card kind per NetworkEventKind, colour-coded per + * the sketch palette. + */ + +const CARD_TTL_MS = 8_000; +const MAX_VISIBLE = 5; + +const KIND_GLYPH: Record = { + council_formed: "★", + provider_added: "✓", + provider_removed: "✗", + asset_registered: "+", + channel_deposit: "↙", + channel_settlement: "↗", + channel_bundle: "•", +}; + +const KIND_TITLE: Record = { + council_formed: "New council formed", + provider_added: "PP joined", + provider_removed: "PP left", + asset_registered: "Asset registered", + channel_deposit: "Deposit", + channel_settlement: "Settlement", + channel_bundle: "Bundle", +}; + +const STATUS_COPY: Record = { + idle: "idle", + connecting: "connecting…", + open: "live", + closed: "disconnected", +}; + +function councilLabel(event: NetworkEvent): string { + if (event.councilName?.trim()) return event.councilName; + return `Council ${truncateAddress(event.councilId)}`; +} + +function detailFor(event: NetworkEvent): string { + const p = event.payload; + switch (event.kind) { + case "provider_added": + case "provider_removed": + return typeof p.providerPublicKey === "string" + ? truncateAddress(p.providerPublicKey) + : ""; + case "channel_deposit": + case "channel_settlement": + if (typeof p.amount !== "string") return ""; + try { + return `${formatAmount(p.amount)}`; + } catch { + return ""; + } + case "council_formed": + return truncateAddress(event.councilId); + case "asset_registered": + return typeof p.assetContractId === "string" + ? truncateAddress(p.assetContractId) + : ""; + case "channel_bundle": + return typeof p.providerPublicKey === "string" + ? `via ${truncateAddress(p.providerPublicKey)}` + : ""; + } +} + +export class ActivityFeed { + private root: HTMLElement; + private list: HTMLDivElement; + private statusEl: HTMLSpanElement; + private seen = new Set(); + private timers = new Map(); + + constructor() { + this.root = document.createElement("aside"); + this.root.className = "section activity-feed"; + this.root.setAttribute("aria-label", "Activity feed"); + this.root.setAttribute("aria-live", "polite"); + + const header = document.createElement("header"); + header.className = "activity-feed-header"; + const title = document.createElement("span"); + title.className = "activity-feed-title"; + title.textContent = "Activity"; + const status = document.createElement("span"); + status.className = "activity-feed-status"; + status.textContent = STATUS_COPY.idle; + this.statusEl = status; + header.append(title, status); + + this.list = document.createElement("div"); + this.list.className = "activity-feed-list"; + + this.root.append(header, this.list); + } + + element(): HTMLElement { + return this.root; + } + + setStatus(status: WsStatus): void { + this.statusEl.textContent = STATUS_COPY[status]; + this.statusEl.dataset.state = status; + } + + /** + * Replace the feed with the snapshot's recent ring buffer. Used on + * connect + reconnect. + */ + seed(events: NetworkEvent[]): void { + this.clear(); + for (const e of events) this.prepend(e); + } + + /** Add a single live event to the top. */ + append(event: NetworkEvent): void { + this.prepend(event); + } + + // ── internals ────────────────────────────────────────────────────── + + private clear(): void { + this.list.textContent = ""; + this.seen.clear(); + for (const t of this.timers.values()) clearTimeout(t); + this.timers.clear(); + } + + private prepend(event: NetworkEvent): void { + if (this.seen.has(event.id)) return; + this.seen.add(event.id); + + const card = document.createElement("article"); + card.className = `activity-card kind-${event.kind}`; + card.dataset.eventId = event.id; + card.style.setProperty("--ttl-ms", `${CARD_TTL_MS}ms`); + + const glyph = document.createElement("span"); + glyph.className = "activity-glyph"; + glyph.textContent = KIND_GLYPH[event.kind]; + + const body = document.createElement("div"); + body.className = "activity-body"; + + const titleRow = document.createElement("div"); + titleRow.className = "activity-title"; + titleRow.textContent = KIND_TITLE[event.kind]; + + const councilRow = document.createElement("div"); + councilRow.className = "activity-council"; + councilRow.textContent = councilLabel(event); + + body.append(titleRow, councilRow); + + const detail = detailFor(event); + if (detail) { + const d = document.createElement("div"); + d.className = "activity-detail"; + d.textContent = detail; + body.appendChild(d); + } + + card.append(glyph, body); + this.list.prepend(card); + + while (this.list.childElementCount > MAX_VISIBLE) { + const last = this.list.lastElementChild; + if (!last) break; + const eventId = (last as HTMLElement).dataset.eventId; + if (eventId) this.dropTimer(eventId); + last.remove(); + } + + const timer = globalThis.setTimeout(() => { + card.classList.add("fading"); + const finalizer = globalThis.setTimeout(() => { + card.remove(); + this.timers.delete(event.id); + }, 600); + this.timers.set(event.id, finalizer); + }, CARD_TTL_MS); + this.timers.set(event.id, timer); + } + + private dropTimer(eventId: string): void { + const t = this.timers.get(eventId); + if (t !== undefined) { + clearTimeout(t); + this.timers.delete(eventId); + } + } +} diff --git a/src/views/asset-breakdown.ts b/src/views/asset-breakdown.ts new file mode 100644 index 0000000..22e754c --- /dev/null +++ b/src/views/asset-breakdown.ts @@ -0,0 +1,73 @@ +import type { AssetBreakdownRow } from "../lib/network-events.ts"; +import { formatPercent, formatStroops } from "../lib/format.ts"; + +/** + * §4 (right) — Asset breakdown. Horizontal bar per asset showing share + * of total volume settled (deposits + settlements) over the trailing 24h. + * Rows sorted by percent descending. + */ + +export class AssetBreakdown { + private root: HTMLElement; + private list: HTMLElement; + + constructor() { + this.root = document.createElement("section"); + this.root.className = "section asset-breakdown"; + this.root.setAttribute("aria-label", "24h asset volume breakdown"); + + const header = document.createElement("header"); + header.className = "asset-breakdown-header"; + const title = document.createElement("span"); + title.className = "asset-breakdown-title"; + title.textContent = "Asset volume — last 24h"; + header.appendChild(title); + + this.list = document.createElement("div"); + this.list.className = "asset-breakdown-list"; + + this.root.append(header, this.list); + } + + element(): HTMLElement { + return this.root; + } + + render(rows: AssetBreakdownRow[]): void { + this.list.textContent = ""; + if (rows.length === 0) { + const empty = document.createElement("div"); + empty.className = "asset-breakdown-empty"; + empty.textContent = "No volume settled yet in the last 24h."; + this.list.appendChild(empty); + return; + } + for (const row of rows) { + const r = document.createElement("article"); + r.className = "asset-row"; + + const code = document.createElement("span"); + code.className = "asset-code"; + code.textContent = row.assetCode; + + const barBox = document.createElement("div"); + barBox.className = "asset-bar-box"; + const bar = document.createElement("div"); + bar.className = "asset-bar"; + const pct = Math.max(0, Math.min(100, row.percent)); + bar.style.width = `${pct}%`; + barBox.appendChild(bar); + + const percent = document.createElement("span"); + percent.className = "asset-percent"; + percent.textContent = formatPercent(row.percent); + + const amount = document.createElement("span"); + amount.className = "asset-amount"; + amount.textContent = formatStroops(row.amountStroops); + + r.append(code, barBox, percent, amount); + this.list.appendChild(r); + } + } +} diff --git a/src/views/council-detail.ts b/src/views/council-detail.ts deleted file mode 100644 index e19b457..0000000 --- a/src/views/council-detail.ts +++ /dev/null @@ -1,274 +0,0 @@ -/** - * Council detail view — drill into a council's channels, PPs, and activity. - */ -import { getNav } from "../lib/nav.ts"; -import { getCouncils } from "../lib/config.ts"; -import { - clearQueryErrors, - countProvidersFromEvents, - getChannelSupply, - getContractEvents, - queryErrors, -} from "../lib/stellar.ts"; -import { - escapeHtml, - formatAmount, - sanitizeUrl, - timeAgo, - truncateAddress, -} from "../lib/dom.ts"; -import { getCountryName } from "../lib/world-map.ts"; -import { onCleanup } from "../lib/router.ts"; -import type { CouncilConfig } from "../lib/config.ts"; -import type { ContractEvent } from "../lib/stellar.ts"; - -// deno-lint-ignore require-await -- view fn satisfies router's Promise contract -export async function councilDetailView( - params?: Record, -): Promise { - const el = document.createElement("div"); - el.appendChild(getNav()); - - const main = document.createElement("main"); - main.className = "container"; - - let councilId = ""; - try { - councilId = params?.id ? decodeURIComponent(params.id) : ""; - } catch { - // Malformed percent-encoding in URL - } - - main.innerHTML = `
Loading council...
`; - el.appendChild(main); - - const ctx = { cancelled: false }; - onCleanup(() => { - ctx.cancelled = true; - }); - - getCouncils() - .then((councils) => { - if (ctx.cancelled) return; - const council = councils.find((c) => c.channelAuthId === councilId); - - if (!council) { - main.innerHTML = ` -

Council Not Found

-

No council registered with ID ${ - escapeHtml(truncateAddress(councilId)) - }

- Back to councils - `; - return; - } - - const safeWebsite = council.website ? sanitizeUrl(council.website) : null; - - main.innerHTML = ` - -

${escapeHtml(council.name)}

-

${ - escapeHtml(council.channelAuthId) - }

-

${ - council.jurisdictions.map((j) => escapeHtml(getCountryName(j))).join( - ", ", - ) - }${ - safeWebsite - ? ` · ${escapeHtml(council.website!)}` - : "" - }

-
Loading council data...
- `; - - loadCouncilDetail(main, council, ctx).catch(() => {}); - }) - .catch(() => { - if (ctx.cancelled) return; - main.innerHTML = ` -

Council Not Found

-

Unable to load council list.

- Back to councils - `; - }); - - return el; -} - -async function loadCouncilDetail( - main: HTMLElement, - council: CouncilConfig, - ctx: { cancelled: boolean }, -): Promise { - clearQueryErrors(); - const channelData: { id: string; asset: string; supply: bigint }[] = []; - const allEvents: ContractEvent[] = []; - - const promises: Promise[] = []; - - for (const ch of council.channels) { - promises.push( - getChannelSupply(ch.privacyChannelId).then((supply) => { - channelData.push({ - id: ch.privacyChannelId, - asset: ch.assetCode, - supply, - }); - }), - ); - } - - promises.push( - getContractEvents(council.channelAuthId, undefined, 100).then((events) => { - allEvents.push(...events); - }), - ); - - for (const ch of council.channels) { - promises.push( - getContractEvents(ch.privacyChannelId, undefined, 100).then((events) => { - allEvents.push(...events); - }), - ); - } - - await Promise.allSettled(promises); - if (ctx.cancelled) return; - - allEvents.sort((a, b) => b.ledger - a.ledger); - - const content = main.querySelector("#council-detail-content"); - if (!content) return; - - const totalSupply = channelData.reduce((sum, c) => sum + c.supply, 0n); - const txEvents = allEvents.filter((e) => - !["ProviderAdded", "ProviderRemoved", "ContractInitialized"].includes( - e.type, - ) - ); - - // Derive active providers using chronological event processing - const authEvents = allEvents.filter((e) => - e.contractId === council.channelAuthId && - (e.type === "ProviderAdded" || e.type === "ProviderRemoved") - ); - authEvents.sort((a, b) => a.ledger - b.ledger); // chronological order - const activeProviders = countProvidersFromEvents(authEvents); - - const hasErrors = queryErrors.length > 0; - - content.innerHTML = ` - ${ - hasErrors - ? `
Some data may be incomplete — network queries failed.
` - : "" - } - -
-
- ${channelData.length} - Channels -
-
- ${activeProviders.length} - Providers (recent) -
-
- ${formatAmount(totalSupply)} - Total Supply -
-
- ${txEvents.length} - Recent Txns -
-
- -

Channels

- - - - - - - - - - ${ - channelData.map((ch) => ` - - - - - - `).join("") - } - -
Channel IDAssetSupply
${truncateAddress(ch.id)}${escapeHtml(ch.asset)}${formatAmount(ch.supply)}
- - ${ - activeProviders.length > 0 - ? ` -

Registered Providers

- - - - - - - - ${ - activeProviders.map((p) => ` - - - - `).join("") - } - -
Provider Address
${escapeHtml(p)}
- ` - : ` -

Registered Providers

-

No providers discovered from recent on-chain events.

- ` - } - -

Recent Activity

- ${ - allEvents.length > 0 - ? ` -
- ${ - allEvents.slice(0, 50).map((e) => ` -
-
- ${escapeHtml(e.type)} - ${ - e.timestamp ? timeAgo(e.timestamp) : `Ledger ${e.ledger}` - } -
-
${ - truncateAddress(e.contractId) - }
-
- `).join("") - } -
- ` - : ` -

No recent activity found.

- ` - } - `; -} diff --git a/src/views/council-details.ts b/src/views/council-details.ts new file mode 100644 index 0000000..f22cfb9 --- /dev/null +++ b/src/views/council-details.ts @@ -0,0 +1,223 @@ +import type { + CouncilRollingMetrics, + CouncilTopologyEntry, +} from "../lib/network-events.ts"; +import { truncateAddress } from "../lib/dom.ts"; +import { formatStroops } from "../lib/format.ts"; + +/** + * §3 — Council details. Collapsed by default with a "click a council + * node for details" hint. When a council is selected, paints: + * - Council name + deployed-age (when available) + * - Jurisdiction + * - Channels count + total supply (sum of deposit minus settlement + * volume across council channels in the trailing hour) + * - Member PPs with full pubkey list + * - Assets registered (count + SAC symbols) + * - Recent activity (bundles last hour, rolling rate, deposit + settlement + * volume) + * + * The sketch deliberately omits a channels table — channels = assets, so + * the asset list covers it. + */ + +const EMPTY_METRICS: CouncilRollingMetrics = { + bundlesLastHour: 0, + eventsLastHour: 0, + ratePerMin: 0, + depositVolumeStroops: "0", + settlementVolumeStroops: "0", +}; + +export class CouncilDetails { + private root: HTMLElement; + private hint: HTMLElement; + private panel: HTMLElement; + private councilsById = new Map(); + private rolling: Record = {}; + private selectedCouncilId: string | null = null; + + constructor() { + this.root = document.createElement("section"); + this.root.className = "section council-details"; + + this.hint = document.createElement("div"); + this.hint.className = "council-details-hint"; + this.hint.textContent = "Click a council node for details."; + + this.panel = document.createElement("div"); + this.panel.className = "council-details-panel hidden"; + + this.root.append(this.hint, this.panel); + } + + element(): HTMLElement { + return this.root; + } + + setTopology(topology: CouncilTopologyEntry[]): void { + this.councilsById.clear(); + for (const c of topology) this.councilsById.set(c.id, c); + if ( + this.selectedCouncilId && !this.councilsById.has(this.selectedCouncilId) + ) { + this.clear(); + return; + } + if (this.selectedCouncilId) this.paint(); + } + + setRollingMetrics(rolling: Record): void { + this.rolling = rolling; + if (this.selectedCouncilId) this.paint(); + } + + select(councilId: string): void { + if (!this.councilsById.has(councilId)) return; + this.selectedCouncilId = councilId; + this.paint(); + } + + clear(): void { + this.selectedCouncilId = null; + this.panel.classList.add("hidden"); + this.hint.classList.remove("hidden"); + this.panel.textContent = ""; + } + + // ── internals ────────────────────────────────────────────────────── + + private paint(): void { + if (!this.selectedCouncilId) return; + const council = this.councilsById.get(this.selectedCouncilId); + if (!council) { + this.clear(); + return; + } + const metrics = this.rolling[council.id] ?? EMPTY_METRICS; + + this.panel.textContent = ""; + this.panel.classList.remove("hidden"); + this.hint.classList.add("hidden"); + + const headerRow = document.createElement("header"); + headerRow.className = "council-details-header"; + const nameEl = document.createElement("h2"); + nameEl.className = "council-details-name"; + nameEl.textContent = council.name ?? + `Council ${truncateAddress(council.id)}`; + headerRow.appendChild(nameEl); + if (council.jurisdictions.length > 0) { + const j = document.createElement("span"); + j.className = "council-details-jurisdiction"; + j.textContent = council.jurisdictions.join(" · "); + headerRow.appendChild(j); + } + this.panel.appendChild(headerRow); + + const grid = document.createElement("div"); + grid.className = "council-details-grid"; + + const channelCount = council.channels.length; + const totalSupply = formatStroops( + this.computeTotalSupply(metrics), + ); + + grid.appendChild( + kvBlock("Channels", String(channelCount)), + ); + grid.appendChild( + kvBlock("Net supply (1h)", totalSupply, "deposits − settlements"), + ); + grid.appendChild( + kvBlock( + "Member PPs", + String(council.providers.length), + ), + ); + grid.appendChild( + kvBlock( + "Assets", + String( + new Set( + council.channels + .map((c) => c.assetContractId) + .filter((id): id is string => !!id), + ).size, + ), + council.channels.map((c) => c.assetCode).join(", "), + ), + ); + grid.appendChild( + kvBlock("Bundles (1h)", String(metrics.bundlesLastHour)), + ); + grid.appendChild( + kvBlock("Events / min", metrics.ratePerMin.toFixed(1)), + ); + grid.appendChild( + kvBlock( + "Deposits (1h)", + formatStroops(metrics.depositVolumeStroops), + ), + ); + grid.appendChild( + kvBlock( + "Settlements (1h)", + formatStroops(metrics.settlementVolumeStroops), + ), + ); + this.panel.appendChild(grid); + + if (council.providers.length > 0) { + const ppHeader = document.createElement("h3"); + ppHeader.className = "council-details-subheader"; + ppHeader.textContent = "Member PPs"; + this.panel.appendChild(ppHeader); + + const ppList = document.createElement("ul"); + ppList.className = "council-details-pp-list"; + for (const pp of council.providers) { + const li = document.createElement("li"); + li.textContent = pp.publicKey; + ppList.appendChild(li); + } + this.panel.appendChild(ppList); + } + } + + /** + * Net supply over the last hour: deposits − settlements. The sketch + * asks for "Total supply: N" across the council's channels; the backend + * doesn't read live SAC balances (no application chain reads beyond + * watcher events), so the best proxy is the trailing-hour net flow. + * Labelled "Net supply (1h)" to be honest about the window. + */ + private computeTotalSupply(metrics: CouncilRollingMetrics): string { + try { + const deposits = BigInt(metrics.depositVolumeStroops || "0"); + const settlements = BigInt(metrics.settlementVolumeStroops || "0"); + return (deposits - settlements).toString(); + } catch { + return "0"; + } + } +} + +function kvBlock(label: string, value: string, sub?: string): HTMLElement { + const block = document.createElement("div"); + block.className = "kv-block"; + const labelEl = document.createElement("span"); + labelEl.className = "kv-label"; + labelEl.textContent = label; + const valueEl = document.createElement("span"); + valueEl.className = "kv-value"; + valueEl.textContent = value; + block.append(labelEl, valueEl); + if (sub) { + const subEl = document.createElement("span"); + subEl.className = "kv-sub"; + subEl.textContent = sub; + block.appendChild(subEl); + } + return block; +} diff --git a/src/views/councils.ts b/src/views/councils.ts deleted file mode 100644 index 94ec22e..0000000 --- a/src/views/councils.ts +++ /dev/null @@ -1,208 +0,0 @@ -/** - * Council list view — all registered councils with on-chain state. - */ -import { getNav } from "../lib/nav.ts"; -import { getCouncils } from "../lib/config.ts"; -import { - clearQueryErrors, - countProvidersFromEvents, - getChannelSupply, - getContractEvents, - queryErrors, -} from "../lib/stellar.ts"; -import { escapeHtml, formatAmount, truncateAddress } from "../lib/dom.ts"; -import { getCountryName } from "../lib/world-map.ts"; -import { onCleanup } from "../lib/router.ts"; - -interface CouncilState { - name: string; - channelAuthId: string; - jurisdictions: string[]; - website?: string; - channels: { - privacyChannelId: string; - assetCode: string; - supply: bigint; - }[]; - providerCount: number; - loading: boolean; -} - -// deno-lint-ignore require-await -- view fn satisfies router's Promise contract -export async function councilsView(): Promise { - const el = document.createElement("div"); - el.appendChild(getNav()); - - const main = document.createElement("main"); - main.className = "container"; - main.innerHTML = ` -

Councils

-

All registered councils and their on-chain state.

-
Loading council data...
- `; - el.appendChild(main); - - const ctx = { cancelled: false }; - onCleanup(() => { - ctx.cancelled = true; - }); - - loadCouncilData(main, ctx).catch(() => {}); - - return el; -} - -async function loadCouncilData( - main: HTMLElement, - ctx: { cancelled: boolean }, -): Promise { - clearQueryErrors(); - const councils = await getCouncils(); - if (ctx.cancelled) return; - - const states: CouncilState[] = councils.map((council) => ({ - name: council.name, - channelAuthId: council.channelAuthId, - jurisdictions: council.jurisdictions, - website: council.website, - channels: [], - providerCount: 0, - loading: true, - })); - - renderCouncilTable(main, states); - - const promises: Promise[] = []; - - for (const state of states) { - const council = councils.find((c) => - c.channelAuthId === state.channelAuthId - )!; - - for (const ch of council.channels) { - promises.push( - getChannelSupply(ch.privacyChannelId).then((supply) => { - state.channels.push({ - privacyChannelId: ch.privacyChannelId, - assetCode: ch.assetCode, - supply, - }); - }), - ); - } - - promises.push( - getContractEvents(state.channelAuthId).then((events) => { - state.providerCount = countProvidersFromEvents(events).length; - }), - ); - } - - await Promise.allSettled(promises); - - if (ctx.cancelled) return; - - for (const state of states) { - state.loading = false; - } - renderCouncilTable(main, states); -} - -function renderCouncilTable(main: HTMLElement, states: CouncilState[]): void { - const content = main.querySelector("#councils-content"); - if (!content) return; - - if (states.length === 0) { - content.innerHTML = - `

No councils registered yet.

`; - return; - } - - const totalChannels = states.reduce((sum, s) => sum + s.channels.length, 0); - const totalProviders = states.reduce((sum, s) => sum + s.providerCount, 0); - const totalSupply = states.reduce( - (sum, s) => sum + s.channels.reduce((cs, c) => cs + c.supply, 0n), - 0n, - ); - - const hasErrors = queryErrors.length > 0; - - content.innerHTML = ` - ${ - hasErrors - ? `
Some data may be incomplete — network queries failed. (${queryErrors.length} error${ - queryErrors.length !== 1 ? "s" : "" - })
` - : "" - } - -
-
- ${states.length} - Councils -
-
- ${totalChannels} - Channels -
-
- ${totalProviders} - Providers (recent) -
-
- ${formatAmount(totalSupply)} - Total Supply -
-
- - - - - - - - - - - - - - ${ - states.map((s) => { - const supply = s.channels.reduce((sum, c) => sum + c.supply, 0n); - return ` - - - - - - - - - `; - }).join("") - } - -
CouncilJurisdictionChannelsProvidersTotal SupplyStatus
-
${escapeHtml(s.name)}
-
${ - truncateAddress(s.channelAuthId) - }
-
${ - s.jurisdictions.map((j) => escapeHtml(getCountryName(j))).join(", ") - }${s.channels.length}${ - s.loading ? '...' : s.providerCount - }${ - s.loading ? '...' : formatAmount(supply) - }Active
- `; - - content.querySelectorAll(".clickable-row").forEach((row) => { - row.addEventListener("click", () => { - const href = row.getAttribute("data-href"); - if (href) globalThis.location.hash = href; - }); - }); -} diff --git a/src/views/counter-strip.ts b/src/views/counter-strip.ts new file mode 100644 index 0000000..4a67ed4 --- /dev/null +++ b/src/views/counter-strip.ts @@ -0,0 +1,93 @@ +import type { Counters } from "../lib/network-events.ts"; +import { formatCount, formatLatencyMs } from "../lib/format.ts"; + +/** + * §1 — Counter strip. Six full-width tiles. Order matches the locked + * sketch and the SnapshotFrame.counters field order. + */ + +type TileKey = keyof Counters; + +const TILE_ORDER: Array<{ key: TileKey; label: string }> = [ + { key: "councils", label: "COUNCILS" }, + { key: "activePPs", label: "ACTIVE PPs" }, + { key: "eventsLast24h", label: "EVENTS / 24H" }, + { key: "assetsRegistered", label: "ASSETS" }, + { key: "throughputPerMin", label: "THROUGHPUT" }, + { key: "latencyMs", label: "LATENCY" }, +]; + +const ZERO_COUNTERS: Counters = { + councils: 0, + activePPs: 0, + eventsLast24h: 0, + assetsRegistered: 0, + throughputPerMin: 0, + latencyMs: null, +}; + +export class CounterStrip { + private root: HTMLElement; + private tiles = new Map(); + private state: Counters = { ...ZERO_COUNTERS }; + + constructor() { + this.root = document.createElement("section"); + this.root.className = "section counter-strip"; + this.root.setAttribute("aria-label", "Network counters"); + for (const { key, label } of TILE_ORDER) { + const tile = document.createElement("article"); + tile.className = "counter-tile"; + tile.dataset.counter = String(key); + + const labelEl = document.createElement("div"); + labelEl.className = "counter-label"; + labelEl.textContent = label; + + const valueEl = document.createElement("div"); + valueEl.className = "counter-value"; + valueEl.textContent = "—"; + + tile.append(labelEl, valueEl); + this.tiles.set(key, valueEl); + this.root.appendChild(tile); + } + this.paint(); + } + + element(): HTMLElement { + return this.root; + } + + render(counters: Counters): void { + this.state = { ...counters }; + this.paint(); + } + + /** + * Per-event ticker for EVENTS / 24H. The snapshot is the source of + * truth; this gives the user a "something happened" tick between + * snapshot updates so the layout doesn't feel frozen. + */ + bumpFromLiveEvent(): void { + this.state = { + ...this.state, + eventsLast24h: this.state.eventsLast24h + 1, + }; + const el = this.tiles.get("eventsLast24h"); + if (el) el.textContent = formatCount(this.state.eventsLast24h); + } + + private paint(): void { + for (const { key } of TILE_ORDER) { + const el = this.tiles.get(key); + if (!el) continue; + el.textContent = this.formatValue(key, this.state); + } + } + + private formatValue(key: TileKey, counters: Counters): string { + if (key === "latencyMs") return formatLatencyMs(counters.latencyMs); + return formatCount(counters[key] as number); + } +} diff --git a/src/views/map.ts b/src/views/map.ts deleted file mode 100644 index c8c5a04..0000000 --- a/src/views/map.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Map view — world map with councils plotted by jurisdiction. - * Uses a static SVG map (simple-world-map, CC BY-SA 3.0). - */ -import { getNav } from "../lib/nav.ts"; -import { type CouncilConfig, getCouncils } from "../lib/config.ts"; -import { - fetchWorldSvg, - getCountryName, - projectCountry, -} from "../lib/world-map.ts"; -import { escapeHtml, truncateAddress } from "../lib/dom.ts"; -import { onCleanup } from "../lib/router.ts"; - -export async function mapView(): Promise { - const el = document.createElement("div"); - el.appendChild(getNav()); - - const main = document.createElement("main"); - main.className = "container"; - main.innerHTML = ` -

Network Map

-

Councils by declared jurisdiction. Dot size reflects number of channels.

-
-
Loading map...
-
-

Councils by Jurisdiction

-
Loading councils...
- `; - el.appendChild(main); - - const ctx = { cancelled: false }; - onCleanup(() => { - ctx.cancelled = true; - }); - - const councils = await getCouncils(); - if (ctx.cancelled) return el; - - const grid = main.querySelector(".council-grid"); - if (grid) { - grid.innerHTML = councils.length === 0 - ? `

No councils registered yet.

` - : councils.map((c) => ` - -
- ${escapeHtml(c.name)} - ${c.channels.length} channel${ - c.channels.length !== 1 ? "s" : "" - } -
-
- ${ - c.jurisdictions.map((j) => escapeHtml(getCountryName(j))).join(", ") - } -
-
${ - truncateAddress(c.channelAuthId) - }
-
- `).join(""); - } - - try { - const svgText = await fetchWorldSvg(); - if (ctx.cancelled) return el; - - const mapContainer = main.querySelector(".map-container")!; - - // Parse the SVG to extract paths, then wrap in our styled SVG - const parser = new DOMParser(); - const svgDoc = parser.parseFromString(svgText, "image/svg+xml"); - const origSvg = svgDoc.querySelector("svg"); - if (!origSvg) throw new Error("Invalid SVG"); - - const viewBox = origSvg.getAttribute("viewBox") || "0 0 800 500"; - - // Extract all path elements - const pathElements = svgDoc.querySelectorAll("path"); - const pathStrings: string[] = []; - pathElements.forEach((p) => { - const d = p.getAttribute("d"); - if (d) pathStrings.push(d); - }); - - const dots = buildCouncilMarkers(councils); - - mapContainer.innerHTML = ` - - - - - - - ${pathStrings.map((d) => ``).join("\n ")} - - - - ${dots} - - `; - } catch (err) { - console.warn("[map] Failed to load world map:", err); - if (!ctx.cancelled) { - const mapContainer = main.querySelector(".map-container")!; - mapContainer.innerHTML = - `

Failed to load map. Please try again later.

`; - } - } - - return el; -} - -function buildCouncilMarkers(councils: CouncilConfig[]): string { - const markers: string[] = []; - - for (const council of councils) { - for (const code of council.jurisdictions) { - const pos = projectCountry(code, 0, 0); - if (!pos) continue; - - const channels = council.channels.length; - const r = Math.min(4 + channels * 2, 10); - - markers.push( - ``, - ); - markers.push( - ``, - ); - markers.push( - `` + - `${escapeHtml(council.name)} — ${ - escapeHtml(getCountryName(code)) - }`, - ); - markers.push( - `${escapeHtml(council.name)}`, - ); - } - } - - return markers.join("\n "); -} diff --git a/src/views/sparklines.ts b/src/views/sparklines.ts new file mode 100644 index 0000000..8b32e81 --- /dev/null +++ b/src/views/sparklines.ts @@ -0,0 +1,170 @@ +import type { Sparklines } from "../lib/network-events.ts"; + +/** + * §4 (left) — Three sparkline charts. 60 buckets per series, one bucket + * per minute (oldest left, newest right). + * + * SVG polyline rendered per series. The Y-axis scales independently per + * chart so a tiny volume series doesn't squash the throughput chart. + */ + +const SVG_NS = "http://www.w3.org/2000/svg"; +const CHART_W = 320; +const CHART_H = 80; +const PAD_X = 8; +const PAD_Y = 10; + +type Series = { + key: keyof Sparklines; + title: string; + unit: string; + stroke: string; +}; + +const SERIES: Series[] = [ + { + key: "throughput", + title: "Throughput", + unit: "events/min", + stroke: "#1c7ed6", + }, + { key: "latency", title: "Latency", unit: "ms (avg)", stroke: "#9775fa" }, + { key: "volume", title: "Volume", unit: "asset units", stroke: "#2f9e44" }, +]; + +export class SparklineGroup { + private root: HTMLElement; + private charts = new Map(); + + constructor() { + this.root = document.createElement("section"); + this.root.className = "section sparklines"; + this.root.setAttribute("aria-label", "60-minute rolling sparklines"); + + for (const s of SERIES) { + const card = document.createElement("article"); + card.className = "sparkline-card"; + + const header = document.createElement("header"); + header.className = "sparkline-header"; + const title = document.createElement("span"); + title.className = "sparkline-title"; + title.textContent = s.title; + const value = document.createElement("span"); + value.className = "sparkline-value"; + value.textContent = "—"; + header.append(title, value); + + const svg = document.createElementNS(SVG_NS, "svg"); + svg.setAttribute("viewBox", `0 0 ${CHART_W} ${CHART_H}`); + svg.setAttribute("preserveAspectRatio", "none"); + svg.classList.add("sparkline-svg"); + + const baseline = document.createElementNS(SVG_NS, "line"); + baseline.setAttribute("x1", String(PAD_X)); + baseline.setAttribute("y1", String(CHART_H - PAD_Y)); + baseline.setAttribute("x2", String(CHART_W - PAD_X)); + baseline.setAttribute("y2", String(CHART_H - PAD_Y)); + baseline.setAttribute("stroke", "#dee2e6"); + baseline.setAttribute("stroke-width", "1"); + + const line = document.createElementNS(SVG_NS, "polyline"); + line.setAttribute("fill", "none"); + line.setAttribute("stroke", s.stroke); + line.setAttribute("stroke-width", "2"); + line.setAttribute("stroke-linejoin", "round"); + line.setAttribute("stroke-linecap", "round"); + + svg.append(baseline, line); + + const unit = document.createElement("span"); + unit.className = "sparkline-unit"; + unit.textContent = s.unit; + + card.append(header, svg, unit); + this.root.appendChild(card); + this.charts.set(s.key, { svg, line, valueEl: value }); + } + } + + element(): HTMLElement { + return this.root; + } + + render(sparklines: Sparklines): void { + this.paintSeries("throughput", sparklines.throughput); + this.paintLatency(sparklines.latency); + this.paintSeries("volume", sparklines.volume); + } + + private paintSeries( + key: "throughput" | "volume", + series: number[], + ): void { + const chart = this.charts.get(key); + if (!chart) return; + if (series.length === 0) { + chart.line.setAttribute("points", ""); + chart.valueEl.textContent = "—"; + return; + } + const max = Math.max(...series, 1); + const points = series.map((v, i) => { + const x = projectX(i, series.length); + const y = projectY(v, max); + return `${x.toFixed(1)},${y.toFixed(1)}`; + }).join(" "); + chart.line.setAttribute("points", points); + const latest = series[series.length - 1]; + chart.valueEl.textContent = formatTrend(latest); + } + + private paintLatency(series: Array): void { + const chart = this.charts.get("latency"); + if (!chart) return; + if (series.length === 0) { + chart.line.setAttribute("points", ""); + chart.valueEl.textContent = "—"; + return; + } + const numeric = series.filter((v): v is number => v !== null); + const max = Math.max(...numeric, 1); + // Bridge nulls with the last seen value so the line stays continuous; + // if there's no seen value yet, anchor at zero so the chart doesn't + // start with a spike. + let lastSeen = 0; + const points = series.map((v, i) => { + if (v !== null) lastSeen = v; + const x = projectX(i, series.length); + const y = projectY(lastSeen, max); + return `${x.toFixed(1)},${y.toFixed(1)}`; + }).join(" "); + chart.line.setAttribute("points", points); + const latest = numeric.length > 0 ? numeric[numeric.length - 1] : null; + chart.valueEl.textContent = latest === null ? "—" : `${latest} ms`; + } +} + +function projectX(i: number, count: number): number { + if (count <= 1) return CHART_W / 2; + const span = CHART_W - PAD_X * 2; + return PAD_X + (span * i) / (count - 1); +} + +function projectY(value: number, max: number): number { + if (!Number.isFinite(value) || value < 0) value = 0; + if (max <= 0) max = 1; + const span = CHART_H - PAD_Y * 2; + return CHART_H - PAD_Y - (span * Math.min(value, max)) / max; +} + +function formatTrend(value: number): string { + if (!Number.isFinite(value)) return "—"; + if (Math.abs(value) >= 1000) return value.toFixed(0); + if (Math.abs(value) >= 10) return value.toFixed(1); + return value.toFixed(2); +} diff --git a/src/views/topology.ts b/src/views/topology.ts new file mode 100644 index 0000000..7449669 --- /dev/null +++ b/src/views/topology.ts @@ -0,0 +1,348 @@ +import type { + CouncilTopologyEntry, + NetworkEvent, + NetworkEventKind, +} from "../lib/network-events.ts"; + +/** + * §2 (left) — Topology. Yellow MOONLIGHT center, council ring, PP-pubkey + * satellites, edges from MOONLIGHT to each council (thickness = recent + * throughput), red pulses on edges for every observed event. + * + * SVG-based; no physics. Positions are recomputed only when the topology + * frame changes (snapshot or hourly re-sync). + */ + +const SVG_NS = "http://www.w3.org/2000/svg"; + +const VIEW_W = 880; +const VIEW_H = 620; +const CX = VIEW_W / 2; +const CY = VIEW_H / 2; + +const MOON_RX = 70; +const MOON_RY = 36; +const COUNCIL_BASE_R = 24; +const PP_R = 5; +const COUNCIL_ORBIT = 210; +const PP_ORBIT_OFFSET = 28; +const PULSE_FADE_MS = 1_000; + +const KIND_PULSE_COLOR: Record = { + council_formed: "#9775fa", + provider_added: "#2f9e44", + provider_removed: "#868e96", + asset_registered: "#f59f00", + channel_deposit: "#e8590c", + channel_settlement: "#1c7ed6", + channel_bundle: "#15aabf", +}; + +const DEFAULT_PULSE_COLOR = "#f03e3e"; + +type CouncilLayout = { + x: number; + y: number; + r: number; + throughput: number; +}; + +export type CouncilClickHandler = (councilId: string) => void; + +export class Topology { + private root: HTMLElement; + private svg: SVGSVGElement; + private edgesLayer: SVGGElement; + private nodesLayer: SVGGElement; + private pulsesLayer: SVGGElement; + private councilLayouts = new Map(); + /** Recent-event count per council, decays linearly via a periodic timer. */ + private recentCounts = new Map(); + private decayTimer: number | null = null; + private onCouncilClick: CouncilClickHandler | null = null; + private selectedCouncilId: string | null = null; + + constructor() { + this.root = document.createElement("section"); + this.root.className = "section topology"; + + this.svg = document.createElementNS(SVG_NS, "svg"); + this.svg.setAttribute("viewBox", `0 0 ${VIEW_W} ${VIEW_H}`); + this.svg.setAttribute("preserveAspectRatio", "xMidYMid meet"); + this.svg.classList.add("topology-svg"); + + this.edgesLayer = this.makeGroup("edges"); + this.nodesLayer = this.makeGroup("nodes"); + this.pulsesLayer = this.makeGroup("pulses"); + this.svg.append(this.edgesLayer, this.nodesLayer, this.pulsesLayer); + this.root.appendChild(this.svg); + + this.decayTimer = globalThis.setInterval( + () => this.decayThroughput(), + 5_000, + ); + } + + element(): HTMLElement { + return this.root; + } + + setCouncilClickHandler(handler: CouncilClickHandler): void { + this.onCouncilClick = handler; + } + + /** + * Redraw the static layers (edges + council/PP nodes) from a topology + * frame. Pulses remain — they are short-lived overlays. + */ + render(topology: CouncilTopologyEntry[]): void { + this.edgesLayer.textContent = ""; + this.nodesLayer.textContent = ""; + this.councilLayouts.clear(); + this.drawCenter(); + if (topology.length === 0) { + this.drawEmptyState(); + return; + } + const angleStep = (Math.PI * 2) / topology.length; + for (let i = 0; i < topology.length; i++) { + const council = topology[i]; + const angle = -Math.PI / 2 + i * angleStep; + const cx = CX + Math.cos(angle) * COUNCIL_ORBIT; + const cy = CY + Math.sin(angle) * COUNCIL_ORBIT; + const r = Math.min( + COUNCIL_BASE_R + council.providers.length * 1.5, + 46, + ); + const layout: CouncilLayout = { x: cx, y: cy, r, throughput: 0 }; + this.councilLayouts.set(council.id, layout); + + const throughput = this.recentCounts.get(council.id) ?? 0; + layout.throughput = throughput; + this.drawEdge(council.id, throughput); + this.drawCouncilNode(council, layout); + } + } + + /** + * Animate a tx pulse from MOONLIGHT to the council associated with the + * event. Also nudges the council's "recent activity" counter so the + * edge thickness grows for a few seconds before decaying. + */ + pulse(event: NetworkEvent): void { + const layout = this.councilLayouts.get(event.councilId); + if (!layout) return; + this.recentCounts.set( + event.councilId, + (this.recentCounts.get(event.councilId) ?? 0) + 1, + ); + this.refreshEdge(event.councilId); + const color = KIND_PULSE_COLOR[event.kind] ?? DEFAULT_PULSE_COLOR; + const pulse = document.createElementNS(SVG_NS, "circle"); + pulse.setAttribute("cx", String(CX)); + pulse.setAttribute("cy", String(CY)); + pulse.setAttribute("r", "8"); + pulse.setAttribute("fill", color); + pulse.setAttribute("opacity", "0.95"); + pulse.classList.add("topology-pulse"); + this.pulsesLayer.appendChild(pulse); + + const animate = pulse.animate( + [ + { cx: `${CX}`, cy: `${CY}`, opacity: 0.95, r: 8 }, + { + cx: `${layout.x}`, + cy: `${layout.y}`, + opacity: 0, + r: 14, + }, + ], + { duration: PULSE_FADE_MS, easing: "ease-out", fill: "forwards" }, + ); + animate.onfinish = () => pulse.remove(); + } + + setSelectedCouncil(councilId: string | null): void { + this.selectedCouncilId = councilId; + for (const [id, _layout] of this.councilLayouts) { + const node = this.nodesLayer.querySelector( + `[data-council-id="${escapeAttrSelector(id)}"]`, + ); + if (node) { + node.classList.toggle("selected", id === councilId); + } + } + } + + destroy(): void { + if (this.decayTimer !== null) { + clearInterval(this.decayTimer); + this.decayTimer = null; + } + } + + // ── internals ────────────────────────────────────────────────────── + + private makeGroup(className: string): SVGGElement { + const g = document.createElementNS(SVG_NS, "g"); + g.setAttribute("class", className); + return g; + } + + private drawCenter(): void { + const ellipse = document.createElementNS(SVG_NS, "ellipse"); + ellipse.setAttribute("cx", String(CX)); + ellipse.setAttribute("cy", String(CY)); + ellipse.setAttribute("rx", String(MOON_RX)); + ellipse.setAttribute("ry", String(MOON_RY)); + ellipse.setAttribute("fill", "#fff3bf"); + ellipse.setAttribute("stroke", "#000"); + ellipse.setAttribute("stroke-width", "3"); + ellipse.classList.add("topology-center"); + this.nodesLayer.appendChild(ellipse); + + const label = document.createElementNS(SVG_NS, "text"); + label.setAttribute("x", String(CX)); + label.setAttribute("y", String(CY + 5)); + label.setAttribute("text-anchor", "middle"); + label.classList.add("topology-label-center"); + label.textContent = "MOONLIGHT"; + this.nodesLayer.appendChild(label); + } + + private drawEmptyState(): void { + const hint = document.createElementNS(SVG_NS, "text"); + hint.setAttribute("x", String(CX)); + hint.setAttribute("y", String(CY + 80)); + hint.setAttribute("text-anchor", "middle"); + hint.classList.add("topology-hint"); + hint.textContent = + "Waiting for councils to register with network-dashboard-platform…"; + this.nodesLayer.appendChild(hint); + } + + private drawEdge(councilId: string, throughput: number): void { + const layout = this.councilLayouts.get(councilId); + if (!layout) return; + const line = document.createElementNS(SVG_NS, "line"); + line.setAttribute("x1", String(CX)); + line.setAttribute("y1", String(CY)); + line.setAttribute("x2", String(layout.x)); + line.setAttribute("y2", String(layout.y)); + line.setAttribute("stroke", "#adb5bd"); + line.setAttribute("stroke-width", String(edgeWidth(throughput))); + line.setAttribute("stroke-linecap", "round"); + line.dataset.councilEdge = councilId; + this.edgesLayer.appendChild(line); + } + + private refreshEdge(councilId: string): void { + const line = this.edgesLayer.querySelector( + `line[data-council-edge="${escapeAttrSelector(councilId)}"]`, + ) as SVGLineElement | null; + if (!line) return; + const throughput = this.recentCounts.get(councilId) ?? 0; + line.setAttribute("stroke-width", String(edgeWidth(throughput))); + } + + private drawCouncilNode( + council: CouncilTopologyEntry, + layout: CouncilLayout, + ): void { + const group = document.createElementNS(SVG_NS, "g"); + group.dataset.councilId = council.id; + group.classList.add("topology-council"); + if (council.id === this.selectedCouncilId) group.classList.add("selected"); + group.style.cursor = "pointer"; + group.addEventListener("click", () => this.onCouncilClick?.(council.id)); + + const circle = document.createElementNS(SVG_NS, "circle"); + circle.setAttribute("cx", String(layout.x)); + circle.setAttribute("cy", String(layout.y)); + circle.setAttribute("r", String(layout.r)); + circle.setAttribute("fill", "#d3f9d8"); + circle.setAttribute("stroke", "#2f9e44"); + circle.setAttribute("stroke-width", "2"); + group.appendChild(circle); + + const name = document.createElementNS(SVG_NS, "text"); + name.setAttribute("x", String(layout.x)); + name.setAttribute("y", String(layout.y - 4)); + name.setAttribute("text-anchor", "middle"); + name.classList.add("topology-label-council"); + name.textContent = council.name ?? truncate(council.id, 10); + group.appendChild(name); + + if (council.jurisdictions.length > 0) { + const j = document.createElementNS(SVG_NS, "text"); + j.setAttribute("x", String(layout.x)); + j.setAttribute("y", String(layout.y + 10)); + j.setAttribute("text-anchor", "middle"); + j.classList.add("topology-label-jurisdiction"); + j.textContent = council.jurisdictions.join(" · "); + group.appendChild(j); + } + + // PP satellite dots: arranged on a small arc just outside the + // council circle, pointing radially away from MOONLIGHT. + const ppCount = council.providers.length; + if (ppCount > 0) { + const baseAngle = Math.atan2(layout.y - CY, layout.x - CX); + const arcSpread = Math.min(Math.PI * 0.6, 0.25 + ppCount * 0.15); + const startAngle = baseAngle - arcSpread / 2; + const angleStep = ppCount === 1 ? 0 : arcSpread / (ppCount - 1); + for (let i = 0; i < ppCount; i++) { + const a = startAngle + angleStep * i; + const px = layout.x + Math.cos(a) * (layout.r + PP_ORBIT_OFFSET); + const py = layout.y + Math.sin(a) * (layout.r + PP_ORBIT_OFFSET); + const dot = document.createElementNS(SVG_NS, "circle"); + dot.setAttribute("cx", String(px)); + dot.setAttribute("cy", String(py)); + dot.setAttribute("r", String(PP_R)); + dot.setAttribute("fill", "#ffe8cc"); + dot.setAttribute("stroke", "#e8590c"); + dot.setAttribute("stroke-width", "1.5"); + group.appendChild(dot); + } + } + + this.nodesLayer.appendChild(group); + } + + /** + * Each tick (every 5s) we subtract 1 from each council's recent count + * so the edge thickness fades back when activity dies down. + */ + private decayThroughput(): void { + let changed = false; + for (const [id, count] of this.recentCounts) { + const next = Math.max(0, count - 1); + if (next !== count) { + this.recentCounts.set(id, next); + changed = true; + } + } + if (!changed) return; + for (const id of this.councilLayouts.keys()) this.refreshEdge(id); + } +} + +function edgeWidth(throughput: number): number { + // Linear ramp clamped between idle (1.2) and saturated (6). + const w = 1.2 + throughput * 0.6; + return Math.min(6, w); +} + +function truncate(value: string, n: number): string { + if (value.length <= n) return value; + return `${value.slice(0, n - 1)}…`; +} + +/** + * CSS attribute selectors don't accept arbitrary strings; the contract + * IDs we receive are alphanumeric, but escape just-in-case to keep + * future inputs safe. + */ +function escapeAttrSelector(value: string): string { + return value.replace(/(["\\])/g, "\\$1"); +} diff --git a/src/views/transactions.ts b/src/views/transactions.ts deleted file mode 100644 index 81fc628..0000000 --- a/src/views/transactions.ts +++ /dev/null @@ -1,203 +0,0 @@ -/** - * Transaction feed — recent transactions across all channels. - */ -import { getNav } from "../lib/nav.ts"; -import { getCouncils } from "../lib/config.ts"; -import { - clearQueryErrors, - getContractEvents, - queryErrors, -} from "../lib/stellar.ts"; -import { escapeHtml, timeAgo, truncateAddress } from "../lib/dom.ts"; -import { onCleanup } from "../lib/router.ts"; -import type { ContractEvent } from "../lib/stellar.ts"; - -interface FeedEntry { - event: ContractEvent; - councilName: string; - channelAsset: string; - channelId: string; -} - -// deno-lint-ignore require-await -- view fn satisfies router's Promise contract -export async function transactionsView(): Promise { - const el = document.createElement("div"); - el.appendChild(getNav()); - - const main = document.createElement("main"); - main.className = "container"; - main.innerHTML = ` -

Transaction Feed

-

Recent on-chain activity across all channels.

-
Loading transactions...
- `; - el.appendChild(main); - - const ctx = { cancelled: false }; - onCleanup(() => { - ctx.cancelled = true; - }); - - loadTransactions(main, ctx).catch(() => {}); - - return el; -} - -async function loadTransactions( - main: HTMLElement, - ctx: { cancelled: boolean }, -): Promise { - clearQueryErrors(); - const councils = await getCouncils(); - if (ctx.cancelled) return; - - const feed: FeedEntry[] = []; - const promises: Promise[] = []; - - for (const council of councils) { - promises.push( - getContractEvents(council.channelAuthId, undefined, 50).then((events) => { - for (const event of events) { - feed.push({ - event, - councilName: council.name, - channelAsset: "\u2014", - channelId: council.channelAuthId, - }); - } - }), - ); - - for (const ch of council.channels) { - promises.push( - getContractEvents(ch.privacyChannelId, undefined, 50).then((events) => { - for (const event of events) { - feed.push({ - event, - councilName: council.name, - channelAsset: ch.assetCode, - channelId: ch.privacyChannelId, - }); - } - }), - ); - } - } - - await Promise.allSettled(promises); - - if (ctx.cancelled) return; - - feed.sort((a, b) => b.event.ledger - a.event.ledger); - - renderFeed(main, feed); -} - -function eventIcon(type: string): string { - switch (type) { - case "ProviderAdded": - return "+PP"; - case "ProviderRemoved": - return "-PP"; - case "ContractInitialized": - return "INIT"; - default: - return "TX"; - } -} - -function eventBadgeClass(type: string): string { - switch (type) { - case "ProviderAdded": - return "badge-active"; - case "ProviderRemoved": - return "badge-inactive"; - case "ContractInitialized": - return "badge-pending"; - default: - return "badge-active"; - } -} - -function renderFeed(main: HTMLElement, feed: FeedEntry[]): void { - const content = main.querySelector("#tx-content"); - if (!content) return; - - if (feed.length === 0) { - const hasErrors = queryErrors.length > 0; - content.innerHTML = ` -
-

No recent transactions found.

- ${ - hasErrors - ? `

Network queries encountered errors. The RPC may be unreachable.

` - : `

Transactions will appear here as channels process bundles.

` - } -
- `; - return; - } - - const txCount = - feed.filter((f) => - !["ProviderAdded", "ProviderRemoved", "ContractInitialized"].includes( - f.event.type, - ) - ).length; - const providerEvents = - feed.filter((f) => - f.event.type === "ProviderAdded" || f.event.type === "ProviderRemoved" - ).length; - - content.innerHTML = ` -
-
- ${feed.length} - Total Events -
-
- ${txCount} - Transactions -
-
- ${providerEvents} - Provider Events -
-
- -
- ${ - feed.slice(0, 100).map((entry) => ` -
-
- ${ - escapeHtml(eventIcon(entry.event.type)) - } - ${escapeHtml(entry.event.type)} - ${ - entry.event.timestamp - ? timeAgo(entry.event.timestamp) - : `Ledger ${entry.event.ledger}` - } -
-
- Council: ${ - escapeHtml(entry.councilName) - } - ${ - entry.channelAsset !== "\u2014" - ? `Asset: ${ - escapeHtml(entry.channelAsset) - }` - : "" - } -
-
${ - truncateAddress(entry.channelId) - }
-
- `).join("") - } -
- `; -} diff --git a/src/views/world-map.ts b/src/views/world-map.ts new file mode 100644 index 0000000..888ab34 --- /dev/null +++ b/src/views/world-map.ts @@ -0,0 +1,177 @@ +import type { CouncilTopologyEntry } from "../lib/network-events.ts"; +import { + COUNTRIES, + fetchWorldSvg, + getCountryName, + projectCountry, + sanitizeSvgPath, +} from "../lib/world-map.ts"; + +/** + * §5 — World map. Bottom strip showing jurisdictions of registered + * councils as small markers on the SVG world atlas. Unknown / unresolvable + * jurisdiction codes are silently dropped; the layout doesn't depend on + * complete coverage. + */ + +const SVG_NS = "http://www.w3.org/2000/svg"; + +export class WorldMap { + private root: HTMLElement; + private mapHost: HTMLDivElement; + private status: HTMLElement; + private pendingTopology: CouncilTopologyEntry[] = []; + private svgRoot: SVGSVGElement | null = null; + private markerLayer: SVGGElement | null = null; + private loading = false; + private failed = false; + + constructor() { + this.root = document.createElement("section"); + this.root.className = "section world-map"; + this.root.setAttribute("aria-label", "Council jurisdictions"); + + const header = document.createElement("header"); + header.className = "world-map-header"; + const title = document.createElement("span"); + title.className = "world-map-title"; + title.textContent = "Council jurisdictions"; + const status = document.createElement("span"); + status.className = "world-map-status"; + status.textContent = "loading…"; + this.status = status; + header.append(title, status); + + this.mapHost = document.createElement("div"); + this.mapHost.className = "world-map-host"; + + this.root.append(header, this.mapHost); + } + + element(): HTMLElement { + return this.root; + } + + render(topology: CouncilTopologyEntry[]): void { + this.pendingTopology = topology; + if (this.failed) { + this.status.textContent = "map unavailable"; + return; + } + if (!this.svgRoot && !this.loading) { + this.loadSvg(); + return; + } + if (this.svgRoot && this.markerLayer) this.repaintMarkers(); + } + + // ── internals ────────────────────────────────────────────────────── + + private async loadSvg(): Promise { + this.loading = true; + try { + const raw = await fetchWorldSvg(); + const parsed = new DOMParser().parseFromString(raw, "image/svg+xml"); + const svgEl = parsed.documentElement; + if (svgEl.nodeName !== "svg") { + throw new Error("world-map.svg did not parse as "); + } + // Re-create an SVG in our namespace; copying nodes wholesale fails + // some browsers' namespace checks when later querying. + const svg = document.createElementNS( + SVG_NS, + "svg", + ) as SVGSVGElement; + const viewBox = svgEl.getAttribute("viewBox") ?? + "30.767 241.591 784.077 458.627"; + svg.setAttribute("viewBox", viewBox); + svg.setAttribute("preserveAspectRatio", "xMidYMid meet"); + svg.classList.add("world-map-svg"); + + const paths = svgEl.querySelectorAll("path"); + const land = document.createElementNS(SVG_NS, "g") as SVGGElement; + land.setAttribute("class", "world-map-land"); + for (const path of Array.from(paths)) { + const d = sanitizeSvgPath(path.getAttribute("d") ?? ""); + if (!d) continue; + const id = path.getAttribute("id"); + const newPath = document.createElementNS(SVG_NS, "path"); + newPath.setAttribute("d", d); + newPath.setAttribute("fill", "#f1f3f5"); + newPath.setAttribute("stroke", "#dee2e6"); + newPath.setAttribute("stroke-width", "0.5"); + if (id) newPath.setAttribute("data-iso", id.toUpperCase()); + land.appendChild(newPath); + } + + const markers = document.createElementNS(SVG_NS, "g") as SVGGElement; + markers.setAttribute("class", "world-map-markers"); + + svg.append(land, markers); + this.mapHost.textContent = ""; + this.mapHost.appendChild(svg); + + this.svgRoot = svg; + this.markerLayer = markers; + this.repaintMarkers(); + this.status.textContent = "ready"; + } catch (err) { + this.failed = true; + console.warn("World map failed to load", err); + this.status.textContent = "map unavailable"; + this.mapHost.textContent = ""; + } finally { + this.loading = false; + } + } + + private repaintMarkers(): void { + if (!this.markerLayer) return; + this.markerLayer.textContent = ""; + + const seen = new Map(); + for (const c of this.pendingTopology) { + for (const code of c.jurisdictions) { + const upper = code.toUpperCase(); + if (!COUNTRIES[upper]) continue; + const bucket = seen.get(upper) ?? []; + bucket.push(c.name ?? c.id); + seen.set(upper, bucket); + } + } + + if (seen.size === 0) { + const hint = document.createElementNS(SVG_NS, "text"); + hint.setAttribute("x", "400"); + hint.setAttribute("y", "470"); + hint.setAttribute("text-anchor", "middle"); + hint.classList.add("world-map-hint"); + hint.textContent = "No council jurisdictions resolved yet."; + this.markerLayer.appendChild(hint); + this.status.textContent = "no jurisdictions"; + return; + } + + for (const [code, councils] of seen) { + const projected = projectCountry(code, 0, 0); + if (!projected) continue; + const group = document.createElementNS(SVG_NS, "g"); + group.classList.add("world-map-marker"); + group.dataset.iso = code; + + const dot = document.createElementNS(SVG_NS, "circle"); + dot.setAttribute("cx", String(projected.x)); + dot.setAttribute("cy", String(projected.y)); + dot.setAttribute("r", "6"); + dot.setAttribute("fill", "#e8590c"); + dot.setAttribute("stroke", "#ffe8cc"); + dot.setAttribute("stroke-width", "2"); + + const title = document.createElementNS(SVG_NS, "title"); + title.textContent = `${getCountryName(code)} — ${councils.join(", ")}`; + group.append(dot, title); + this.markerLayer.appendChild(group); + } + this.status.textContent = `${seen.size} jurisdictions`; + } +} From 543551e6b376f176b27c30bf6991cbcd22627b62 Mon Sep 17 00:00:00 2001 From: Gorka Date: Wed, 20 May 2026 09:48:04 -0300 Subject: [PATCH 2/8] feat(ui): restore @moonlight/ui nav + dark-theme tokens MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wraps the 5-section dashboard in @moonlight/ui's renderNav + pageLayout (same pattern as provider-console / council-console) so the brand label 'Network Dashboard · v${version}' + nav strip live above the content. build.ts again concatenates tokens.css + base-styles.css + nav.css from the pinned @moonlight/ui@v0.3.1 tag. app-styles.css rewritten to consume the upstream dark tokens (--bg / --surface / --border / --text / --text-muted / --primary / --active / --pending / --inactive) instead of the ad-hoc light palette. Event-kind accents move from background fills to left-border + glyph-coloured accents so cards read against the dark surface. World-map land paths get a CSS override (`fill: var(--border) !important`) to match the theme. --- deno.json | 4 +- src/app-styles.css | 401 ++++++++++++++++++++++++--------------------- src/app.ts | 19 ++- src/build.ts | 33 +++- 4 files changed, 256 insertions(+), 201 deletions(-) diff --git a/deno.json b/deno.json index a1aba03..b1268c6 100644 --- a/deno.json +++ b/deno.json @@ -17,6 +17,8 @@ }, "imports": { "@std/path": "jsr:@std/path@^1.0.0", - "@std/assert": "jsr:@std/assert@^1.0.0" + "@std/assert": "jsr:@std/assert@^1.0.0", + "@moonlight/ui/nav": "https://raw.githubusercontent.com/Moonlight-Protocol/ui/v0.3.1/src/nav/mod.ts", + "@moonlight/ui/layout": "https://raw.githubusercontent.com/Moonlight-Protocol/ui/v0.3.1/src/layout/mod.ts" } } diff --git a/src/app-styles.css b/src/app-styles.css index fa653b3..bafde60 100644 --- a/src/app-styles.css +++ b/src/app-styles.css @@ -1,63 +1,63 @@ /* * App-specific styles for network-dashboard. * - * The build pipeline concatenates @moonlight/ui's tokens + base-styles - * + nav onto this file at build time. Keep this file scoped to the - * dashboard's own 5-section layout. + * Tokens + base-styles + nav come from @moonlight/ui at build time + * (concatenated by build.ts). This file only defines the 5-section + * layout + the SVG-driven topology / sparkline / world-map visual + * vocabulary on top of the upstream dark theme. + * + * All colours are sourced from the @moonlight/ui tokens.css palette + * (--bg / --surface / --border / --text / --text-muted / --primary / + * --active / --pending / --inactive). Event-kind accents below live in + * a small palette here because the sketch maps each kind to a specific + * hue that doesn't have a single token equivalent. */ :root { - --frame-stroke: #333; - --counter-blue: #1971c2; - --counter-blue-bg: #e7f5ff; - --council-green: #2f9e44; - --council-green-bg: #d3f9d8; - --pp-orange: #e8590c; - --pp-orange-bg: #ffe8cc; - --pulse-red: #f03e3e; - --moonlight-yellow: #fff3bf; - --sparkline-blue: #1c7ed6; - --sparkline-purple: #9775fa; - --sparkline-green: #2f9e44; - --activity-green: #d3f9d8; - --activity-gray: #f1f3f5; - --activity-yellow: #fff9db; - --activity-blue: #d0ebff; - --activity-orange: #ffe8cc; - --activity-purple: #e5dbff; - --activity-teal: #c5f6fa; -} - -body { - margin: 0; - font-family: - -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu, - sans-serif; - color: #212529; - background: #f8f9fa; -} - -#app { - min-height: 100vh; - padding: 16px 20px 32px; - box-sizing: border-box; + /* Per-event-kind accents — dark-mode tuned (saturated, mid-lightness). */ + --kind-council: #a78bfa; /* purple */ + --kind-pp-add: #4ade80; /* green */ + --kind-pp-remove: #6b7280; /* gray */ + --kind-asset: #fbbf24; /* yellow */ + --kind-deposit: #fb923c; /* orange */ + --kind-settlement: #60a5fa; /* blue */ + --kind-bundle: #22d3ee; /* teal */ + --kind-default: #f87171; /* red, fallback pulses */ + + /* Topology + center node. */ + --moonlight-yellow: #fde68a; + --moonlight-yellow-stroke: #f59e0b; + --council-fill: rgba(74, 222, 128, 0.12); + --council-stroke: #4ade80; + --pp-fill: rgba(251, 146, 60, 0.18); + --pp-stroke: #fb923c; + + /* Sparkline lines. */ + --sparkline-throughput: #60a5fa; + --sparkline-latency: #a78bfa; + --sparkline-volume: #4ade80; +} + +/* Override @moonlight/ui's .container width — the dashboard sprawls. */ +main.container { + max-width: 1440px; + margin: 1.25rem auto 2rem; + padding: 0 1.5rem; } .dashboard { display: flex; flex-direction: column; - gap: 16px; - max-width: 1440px; - margin: 0 auto; + gap: 1rem; } .row { display: grid; - gap: 16px; + gap: 1rem; } .row.hero { - grid-template-columns: minmax(0, 1fr) 300px; + grid-template-columns: minmax(0, 1fr) 320px; } .row.trends { @@ -65,51 +65,44 @@ body { } .section { - background: #ffffff; - border: 1px solid var(--frame-stroke); - border-radius: 8px; - padding: 12px 16px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.875rem 1rem; box-sizing: border-box; } -.dashboard-footer { - text-align: center; - font-size: 12px; - color: #868e96; - padding: 8px 0 0; -} - /* §1 — Counter strip ──────────────────────────────────────────────── */ .counter-strip { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: 12px; - padding: 12px; + gap: 0.75rem; + padding: 0.75rem; } .counter-tile { - background: var(--counter-blue-bg); - border: 1px solid var(--counter-blue); - border-radius: 6px; - padding: 12px 14px; + background: rgba(99, 102, 241, 0.08); + border: 1px solid rgba(99, 102, 241, 0.35); + border-radius: 8px; + padding: 0.75rem 0.875rem; display: flex; flex-direction: column; - gap: 4px; + gap: 0.25rem; } .counter-label { - font-size: 11px; + font-size: 0.65rem; font-weight: 600; - letter-spacing: 0.04em; - color: var(--counter-blue); + letter-spacing: 0.06em; + color: var(--text-muted); text-transform: uppercase; } .counter-value { - font-size: 24px; + font-size: 1.5rem; font-weight: 700; - color: #0b3d6e; + color: var(--text); font-variant-numeric: tabular-nums; } @@ -119,7 +112,7 @@ body { display: flex; flex-direction: column; min-height: 540px; - padding: 8px; + padding: 0.5rem; } .topology-svg { @@ -129,26 +122,26 @@ body { } .topology-label-center { - font-size: 14px; + font-size: 0.875rem; font-weight: 700; letter-spacing: 0.08em; - fill: #212529; + fill: #1a1d27; } .topology-label-council { - font-size: 12px; + font-size: 0.75rem; font-weight: 600; - fill: #1b4332; + fill: var(--text); } .topology-label-jurisdiction { - font-size: 10px; - fill: #495057; + font-size: 0.65rem; + fill: var(--text-muted); } .topology-hint { - font-size: 14px; - fill: #868e96; + font-size: 0.875rem; + fill: var(--text-muted); font-style: italic; } @@ -157,12 +150,12 @@ body { } .topology-council:hover circle { - filter: brightness(1.06); + filter: brightness(1.15); stroke-width: 3; } .topology-council.selected circle { - stroke: #1864ab; + stroke: var(--primary); stroke-width: 3.5; } @@ -176,63 +169,64 @@ body { display: flex; flex-direction: column; min-height: 540px; - padding: 12px; + padding: 0.75rem; } .activity-feed-header { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 12px; + margin-bottom: 0.75rem; } .activity-feed-title { font-weight: 700; - font-size: 14px; - letter-spacing: 0.04em; + font-size: 0.75rem; + letter-spacing: 0.06em; text-transform: uppercase; + color: var(--text-muted); } .activity-feed-status { - font-size: 11px; - padding: 2px 8px; + font-size: 0.7rem; + padding: 0.125rem 0.5rem; border-radius: 999px; - background: #f1f3f5; - color: #495057; + background: rgba(139, 143, 163, 0.18); + color: var(--text-muted); } .activity-feed-status[data-state="open"] { - background: #d3f9d8; - color: #1b4332; + background: rgba(74, 222, 128, 0.18); + color: var(--active); } .activity-feed-status[data-state="connecting"] { - background: #fff3bf; - color: #5c3c00; + background: rgba(245, 158, 11, 0.18); + color: var(--pending); } .activity-feed-status[data-state="closed"] { - background: #ffe3e3; - color: #8a2a2a; + background: rgba(239, 68, 68, 0.2); + color: var(--inactive); } .activity-feed-list { flex: 1; display: flex; flex-direction: column; - gap: 8px; + gap: 0.5rem; overflow: hidden; } .activity-card { display: flex; - gap: 10px; + gap: 0.625rem; align-items: flex-start; - border-radius: 6px; - border: 1px solid rgba(0, 0, 0, 0.1); - padding: 8px 10px; - background: var(--activity-gray); - animation: card-in 240ms ease-out; + border-radius: 8px; + border-left: 3px solid var(--border); + background: rgba(255, 255, 255, 0.03); + padding: 0.5rem 0.625rem; + animation: card-in 220ms ease-out; opacity: 1; transition: opacity 600ms ease-in, transform 600ms ease-in; } @@ -244,7 +238,7 @@ body { @keyframes card-in { from { - transform: translateY(-4px); + transform: translateY(-6px); opacity: 0; } to { @@ -254,70 +248,93 @@ body { } .activity-card.kind-council_formed { - background: var(--activity-purple); + border-left-color: var(--kind-council); } .activity-card.kind-provider_added { - background: var(--activity-green); + border-left-color: var(--kind-pp-add); } .activity-card.kind-provider_removed { - background: var(--activity-gray); + border-left-color: var(--kind-pp-remove); } .activity-card.kind-asset_registered { - background: var(--activity-yellow); + border-left-color: var(--kind-asset); } .activity-card.kind-channel_settlement { - background: var(--activity-blue); + border-left-color: var(--kind-settlement); } .activity-card.kind-channel_deposit { - background: var(--activity-orange); + border-left-color: var(--kind-deposit); } .activity-card.kind-channel_bundle { - background: var(--activity-teal); + border-left-color: var(--kind-bundle); } .activity-glyph { - font-size: 18px; + font-size: 1.125rem; line-height: 1.1; font-weight: 700; - margin-top: 2px; + margin-top: 0.125rem; + color: var(--text); +} + +.activity-card.kind-council_formed .activity-glyph { + color: var(--kind-council); +} +.activity-card.kind-provider_added .activity-glyph { + color: var(--kind-pp-add); +} +.activity-card.kind-provider_removed .activity-glyph { + color: var(--kind-pp-remove); +} +.activity-card.kind-asset_registered .activity-glyph { + color: var(--kind-asset); +} +.activity-card.kind-channel_settlement .activity-glyph { + color: var(--kind-settlement); +} +.activity-card.kind-channel_deposit .activity-glyph { + color: var(--kind-deposit); +} +.activity-card.kind-channel_bundle .activity-glyph { + color: var(--kind-bundle); } .activity-body { display: flex; flex-direction: column; - gap: 2px; + gap: 0.125rem; min-width: 0; } .activity-title { - font-size: 12px; + font-size: 0.75rem; font-weight: 700; - color: #212529; + color: var(--text); } .activity-council { - font-size: 12px; - color: #495057; + font-size: 0.75rem; + color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .activity-detail { - font-size: 11px; - color: #495057; - font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-size: 0.7rem; + color: var(--text-muted); + font-family: var(--font-mono); } /* §3 — Council details ────────────────────────────────────────────── */ .council-details { - padding: 14px 18px; + padding: 0.875rem 1.125rem; } .council-details-hint { - font-size: 13px; - color: #868e96; + font-size: 0.8125rem; + color: var(--text-muted); font-style: italic; } @@ -328,70 +345,70 @@ body { .council-details-header { display: flex; - gap: 16px; + gap: 1rem; align-items: baseline; - margin-bottom: 10px; + margin-bottom: 0.625rem; } .council-details-name { margin: 0; - font-size: 18px; + font-size: 1.125rem; font-weight: 700; - color: #1b4332; + color: var(--text); } .council-details-jurisdiction { - font-size: 12px; - color: #495057; - background: #f1f3f5; - padding: 2px 8px; + font-size: 0.75rem; + color: var(--text-muted); + background: rgba(139, 143, 163, 0.18); + padding: 0.125rem 0.5rem; border-radius: 999px; } .council-details-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); - gap: 10px; - margin-bottom: 12px; + gap: 0.625rem; + margin-bottom: 0.75rem; } .kv-block { display: flex; flex-direction: column; - gap: 2px; - background: #f8f9fa; - border-radius: 6px; - padding: 8px 10px; - border: 1px solid #e9ecef; + gap: 0.125rem; + background: rgba(255, 255, 255, 0.03); + border-radius: 8px; + padding: 0.5rem 0.625rem; + border: 1px solid var(--border); } .kv-label { - font-size: 11px; + font-size: 0.65rem; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.04em; - color: #495057; + letter-spacing: 0.06em; + color: var(--text-muted); } .kv-value { - font-size: 17px; + font-size: 1.0625rem; font-weight: 700; - color: #212529; + color: var(--text); font-variant-numeric: tabular-nums; } .kv-sub { - font-size: 11px; - color: #868e96; + font-size: 0.65rem; + color: var(--text-muted); } .council-details-subheader { - margin: 12px 0 6px; - font-size: 12px; + margin: 0.75rem 0 0.375rem; + font-size: 0.75rem; font-weight: 700; text-transform: uppercase; - letter-spacing: 0.04em; - color: #495057; + letter-spacing: 0.06em; + color: var(--text-muted); } .council-details-pp-list { @@ -400,16 +417,16 @@ body { padding: 0; display: flex; flex-direction: column; - gap: 4px; - font-family: ui-monospace, "SF Mono", Menlo, monospace; - font-size: 11px; - color: #212529; + gap: 0.25rem; + font-family: var(--font-mono); + font-size: 0.7rem; + color: var(--text); } .council-details-pp-list li { - background: #f8f9fa; - border-radius: 4px; - padding: 4px 8px; + background: rgba(255, 255, 255, 0.03); + border-radius: 6px; + padding: 0.25rem 0.5rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -420,17 +437,17 @@ body { .sparklines { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 12px; + gap: 0.75rem; } .sparkline-card { display: flex; flex-direction: column; - gap: 6px; - padding: 8px 10px; - border: 1px solid #dee2e6; - border-radius: 6px; - background: #ffffff; + gap: 0.375rem; + padding: 0.5rem 0.625rem; + border: 1px solid var(--border); + border-radius: 8px; + background: rgba(255, 255, 255, 0.03); } .sparkline-header { @@ -440,17 +457,17 @@ body { } .sparkline-title { - font-size: 11px; + font-size: 0.65rem; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.04em; - color: #495057; + letter-spacing: 0.06em; + color: var(--text-muted); } .sparkline-value { - font-size: 16px; + font-size: 1rem; font-weight: 700; - color: #212529; + color: var(--text); font-variant-numeric: tabular-nums; } @@ -460,8 +477,8 @@ body { } .sparkline-unit { - font-size: 10px; - color: #868e96; + font-size: 0.625rem; + color: var(--text-muted); align-self: flex-end; } @@ -470,48 +487,48 @@ body { .asset-breakdown { display: flex; flex-direction: column; - gap: 8px; + gap: 0.5rem; } .asset-breakdown-header { - margin-bottom: 4px; + margin-bottom: 0.25rem; } .asset-breakdown-title { - font-size: 11px; + font-size: 0.65rem; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.04em; - color: #495057; + letter-spacing: 0.06em; + color: var(--text-muted); } .asset-breakdown-list { display: flex; flex-direction: column; - gap: 6px; + gap: 0.375rem; } .asset-breakdown-empty { - font-size: 12px; - color: #868e96; + font-size: 0.75rem; + color: var(--text-muted); font-style: italic; } .asset-row { display: grid; grid-template-columns: 60px minmax(0, 1fr) 56px 90px; - gap: 8px; + gap: 0.5rem; align-items: center; - font-size: 12px; + font-size: 0.75rem; } .asset-code { font-weight: 700; - color: #212529; + color: var(--text); } .asset-bar-box { - background: #f1f3f5; + background: rgba(255, 255, 255, 0.05); border-radius: 4px; height: 12px; overflow: hidden; @@ -519,21 +536,26 @@ body { .asset-bar { height: 100%; - background: linear-gradient(90deg, #2f9e44 0%, #82c91e 100%); + background: linear-gradient( + 90deg, + var(--primary) 0%, + var(--primary-hover) 100% + ); border-radius: 4px; transition: width 220ms ease-out; } .asset-percent { font-variant-numeric: tabular-nums; - color: #495057; + color: var(--text-muted); text-align: right; } .asset-amount { font-variant-numeric: tabular-nums; text-align: right; - font-family: ui-monospace, "SF Mono", Menlo, monospace; + font-family: var(--font-mono); + color: var(--text); } /* §5 — World map ──────────────────────────────────────────────────── */ @@ -541,7 +563,7 @@ body { .world-map { display: flex; flex-direction: column; - gap: 8px; + gap: 0.5rem; } .world-map-header { @@ -551,16 +573,16 @@ body { } .world-map-title { - font-size: 11px; + font-size: 0.65rem; font-weight: 600; text-transform: uppercase; - letter-spacing: 0.04em; - color: #495057; + letter-spacing: 0.06em; + color: var(--text-muted); } .world-map-status { - font-size: 11px; - color: #868e96; + font-size: 0.7rem; + color: var(--text-muted); } .world-map-host { @@ -573,9 +595,14 @@ body { height: 100%; } +.world-map-land path { + fill: var(--border) !important; + stroke: rgba(139, 143, 163, 0.35) !important; +} + .world-map-hint { - font-size: 14px; - fill: #adb5bd; + font-size: 0.875rem; + fill: var(--text-muted); } @media (max-width: 960px) { diff --git a/src/app.ts b/src/app.ts index 9453096..c220755 100644 --- a/src/app.ts +++ b/src/app.ts @@ -10,6 +10,8 @@ * §4 Sparklines + Asset breakdown (side-by-side) * §5 World map */ +import { renderNav } from "@moonlight/ui/nav"; +import { pageLayout } from "@moonlight/ui/layout"; import { NETWORK_DASHBOARD_PLATFORM_URL } from "./lib/config.ts"; import { connectNetworkPlatform } from "./lib/ws-client.ts"; import { CounterStrip } from "./views/counter-strip.ts"; @@ -35,8 +37,8 @@ function renderShell(): { if (!app) throw new Error("#app root not found"); app.textContent = ""; - const layout = document.createElement("div"); - layout.className = "dashboard"; + const dashboard = document.createElement("div"); + dashboard.className = "dashboard"; const counters = new CounterStrip(); @@ -56,7 +58,7 @@ function renderShell(): { const worldMap = new WorldMap(); - layout.append( + dashboard.append( counters.element(), heroRow, details.element(), @@ -64,12 +66,11 @@ function renderShell(): { worldMap.element(), ); - const footer = document.createElement("footer"); - footer.className = "dashboard-footer"; - footer.textContent = `Moonlight Network Dashboard · v${__APP_VERSION__}`; - layout.appendChild(footer); - - app.appendChild(layout); + const nav = renderNav({ + brand: "Network Dashboard", + version: __APP_VERSION__, + }); + app.appendChild(pageLayout(nav, dashboard)); return { counters, topology, feed, details, sparklines, assets, worldMap }; } diff --git a/src/build.ts b/src/build.ts index 6a43c27..131d3a5 100644 --- a/src/build.ts +++ b/src/build.ts @@ -17,13 +17,38 @@ async function writeHealthJson(version: string): Promise { console.log(`Built public/health.json (network-dashboard ${version})`); } +// Pinned @moonlight/ui tag. raw.githubusercontent.com serves CSS as +// text/plain with nosniff so browsers refuse @import of these URLs; we +// fetch + concatenate at build time and write the result to public/styles.css. +// Same pattern + tag as provider-console / council-console. +const UI_LIB_TAG = "v0.3.1"; +const UI_LIB_CSS_FILES = [ + "tokens/tokens.css", + "base-styles/base-styles.css", + "nav/nav.css", +]; + async function buildStyles(): Promise { + const parts: string[] = []; + for (const path of UI_LIB_CSS_FILES) { + const url = + `https://raw.githubusercontent.com/Moonlight-Protocol/ui/${UI_LIB_TAG}/src/${path}`; + const res = await fetch(url); + if (!res.ok) { + throw new Error( + `Failed to fetch ${url}: ${res.status} ${res.statusText}`, + ); + } + parts.push( + `/* @moonlight/ui ${UI_LIB_TAG} — ${path} */\n${await res.text()}`, + ); + } const appStyles = await Deno.readTextFile("src/app-styles.css"); - await Deno.writeTextFile( - "public/styles.css", - `/* network-dashboard app-styles */\n${appStyles}`, + parts.push(`/* network-dashboard app-styles */\n${appStyles}`); + await Deno.writeTextFile("public/styles.css", parts.join("\n")); + console.log( + `Built public/styles.css from @moonlight/ui@${UI_LIB_TAG} + src/app-styles.css`, ); - console.log("Built public/styles.css from src/app-styles.css"); } const isProduction = Deno.args.includes("--production"); From c749155d3f6b2a6f50ce2dcf46e02ad993746ce2 Mon Sep 17 00:00:00 2001 From: Gorka Date: Wed, 20 May 2026 10:03:09 -0300 Subject: [PATCH 3/8] chore: refresh deno.lock for @moonlight/ui nav + layout imports --- deno.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/deno.lock b/deno.lock index 6b24ca9..761dfba 100644 --- a/deno.lock +++ b/deno.lock @@ -63,6 +63,7 @@ "https://deno.land/x/denoflate@1.2.1/pkg/denoflate.js": "b9f9ad9457d3f12f28b1fb35c555f57443427f74decb403113d67364e4f2caf4", "https://deno.land/x/denoflate@1.2.1/pkg/denoflate_bg.wasm.js": "d581956245407a2115a3d7e8d85a9641c032940a8e810acbd59ca86afd34d44d", "https://deno.land/x/esbuild@v0.20.1/mod.js": "d50e500b53ce67e31116beba3916b0f9275c0e1cc20bc5cadc0fc1b7a3b06fd9", + "https://raw.githubusercontent.com/Moonlight-Protocol/ui/v0.3.1/src/layout/mod.ts": "064e49573a35de0124de1e7ab2cdfc4f661a3e2415da69f70b0a607c5224f64f", "https://raw.githubusercontent.com/Moonlight-Protocol/ui/v0.3.1/src/nav/mod.ts": "4bce92a8e347e12953f2fc7e4329e0b58e3dcb8d1c872054e2d30beeba4373e0" }, "workspace": { From 7fa12b37680937080d3a625999d4e654348d9c21 Mon Sep 17 00:00:00 2001 From: Gorka Date: Wed, 20 May 2026 16:00:30 -0300 Subject: [PATCH 4/8] =?UTF-8?q?feat(world-map):=20migrate=20=C2=A75=20to?= =?UTF-8?q?=20@moonlight/ui/world-map@v0.3.2=20with=20hover=20popover?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the local lib/world-map.ts with the shared component and reworks views/world-map.ts into a thin section wrapper that drives the new generic onHover + setSlot surface: - hovered country → `hovered` slot (highlighted) - countries sharing a council with the hovered one → `reachable` slot - everything else → `dimmed` slot - a cursor-tracked popover lists each council in the hovered country (name + provider count) or 'No councils' when the country has none The map component stays agnostic — it only fires onHover and exposes setSlot. All council-shaped reasoning lives in this view. CSS for the three slots + the popover ships in app-styles.css. Build pipeline pulls world-map/world-map.css from the shared @moonlight/ui@v0.3.2 tag alongside tokens / base-styles / nav. Drops the now-redundant lib/world-map.ts + lib/geo_test.ts. --- deno.json | 5 +- deno.lock | 6 +- src/app-styles.css | 85 +++++++++++-- src/build.ts | 3 +- src/lib/geo_test.ts | 61 --------- src/lib/world-map.ts | 142 --------------------- src/views/world-map.ts | 283 ++++++++++++++++++++++++----------------- 7 files changed, 249 insertions(+), 336 deletions(-) delete mode 100644 src/lib/geo_test.ts delete mode 100644 src/lib/world-map.ts diff --git a/deno.json b/deno.json index b1268c6..fcf1a4c 100644 --- a/deno.json +++ b/deno.json @@ -18,7 +18,8 @@ "imports": { "@std/path": "jsr:@std/path@^1.0.0", "@std/assert": "jsr:@std/assert@^1.0.0", - "@moonlight/ui/nav": "https://raw.githubusercontent.com/Moonlight-Protocol/ui/v0.3.1/src/nav/mod.ts", - "@moonlight/ui/layout": "https://raw.githubusercontent.com/Moonlight-Protocol/ui/v0.3.1/src/layout/mod.ts" + "@moonlight/ui/nav": "https://raw.githubusercontent.com/Moonlight-Protocol/ui/v0.3.2/src/nav/mod.ts", + "@moonlight/ui/layout": "https://raw.githubusercontent.com/Moonlight-Protocol/ui/v0.3.2/src/layout/mod.ts", + "@moonlight/ui/world-map": "https://raw.githubusercontent.com/Moonlight-Protocol/ui/v0.3.2/src/world-map/mod.ts" } } diff --git a/deno.lock b/deno.lock index 761dfba..da79b4e 100644 --- a/deno.lock +++ b/deno.lock @@ -64,7 +64,11 @@ "https://deno.land/x/denoflate@1.2.1/pkg/denoflate_bg.wasm.js": "d581956245407a2115a3d7e8d85a9641c032940a8e810acbd59ca86afd34d44d", "https://deno.land/x/esbuild@v0.20.1/mod.js": "d50e500b53ce67e31116beba3916b0f9275c0e1cc20bc5cadc0fc1b7a3b06fd9", "https://raw.githubusercontent.com/Moonlight-Protocol/ui/v0.3.1/src/layout/mod.ts": "064e49573a35de0124de1e7ab2cdfc4f661a3e2415da69f70b0a607c5224f64f", - "https://raw.githubusercontent.com/Moonlight-Protocol/ui/v0.3.1/src/nav/mod.ts": "4bce92a8e347e12953f2fc7e4329e0b58e3dcb8d1c872054e2d30beeba4373e0" + "https://raw.githubusercontent.com/Moonlight-Protocol/ui/v0.3.1/src/nav/mod.ts": "4bce92a8e347e12953f2fc7e4329e0b58e3dcb8d1c872054e2d30beeba4373e0", + "https://raw.githubusercontent.com/Moonlight-Protocol/ui/v0.3.2/src/layout/mod.ts": "064e49573a35de0124de1e7ab2cdfc4f661a3e2415da69f70b0a607c5224f64f", + "https://raw.githubusercontent.com/Moonlight-Protocol/ui/v0.3.2/src/nav/mod.ts": "4bce92a8e347e12953f2fc7e4329e0b58e3dcb8d1c872054e2d30beeba4373e0", + "https://raw.githubusercontent.com/Moonlight-Protocol/ui/v0.3.2/src/world-map/countries.ts": "0f403290178dd4040295ad34fe3277a403928fabea314f8fd16d9c51721cb612", + "https://raw.githubusercontent.com/Moonlight-Protocol/ui/v0.3.2/src/world-map/mod.ts": "26b2b745c0df3a9396d47b69c77350c0632f01ccbf059f8da443d737961a6145" }, "workspace": { "dependencies": [ diff --git a/src/app-styles.css b/src/app-styles.css index bafde60..e296dc7 100644 --- a/src/app-styles.css +++ b/src/app-styles.css @@ -585,24 +585,89 @@ main.container { color: var(--text-muted); } -.world-map-host { +/* Section-level wrapper that sizes the shared @moonlight/ui world-map + to the dashboard's §5 slot. The internal .world-map-host / .world-map-svg + styling comes from @moonlight/ui/world-map/world-map.css. */ +.world-map-section-host { width: 100%; height: 320px; + position: relative; } -.world-map-svg { - width: 100%; - height: 100%; +/* Hover-driven slot classes. The ui/world-map module applies these via + setSlot(); the colors / opacities below are this dashboard's choice. */ +.world-map-svg .world-map-country.hovered, +.world-map-svg .world-map-country.hovered path { + fill: var(--primary-hover); } -.world-map-land path { - fill: var(--border) !important; - stroke: rgba(139, 143, 163, 0.35) !important; +.world-map-svg .world-map-country.reachable, +.world-map-svg .world-map-country.reachable path { + fill: #86efac; } -.world-map-hint { - font-size: 0.875rem; - fill: var(--text-muted); +/* Dim by replacing the fill instead of dropping opacity — the base land + color is already dark (#2a2d3a), so opacity 0.25 over the page bg made + un-focused countries read as black. A flat muted fill keeps every + dimmed country to the same quiet tone regardless of whether it was + `selected` before. */ +.world-map-svg .world-map-country.dimmed, +.world-map-svg .world-map-country.dimmed path { + fill: rgba(139, 143, 163, 0.18); +} + +/* Popover anchored to the cursor inside the §5 host. */ +.world-map-popover { + position: absolute; + pointer-events: none; + z-index: 4; + min-width: 180px; + max-width: 280px; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.5rem 0.625rem; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35); + font-size: 0.75rem; + color: var(--text); +} + +.world-map-popover-header { + font-weight: 700; + margin-bottom: 0.375rem; + color: var(--primary); +} + +.world-map-popover-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.world-map-popover-list li { + display: flex; + justify-content: space-between; + gap: 0.5rem; +} + +.world-map-popover-name { + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.world-map-popover-meta { + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +.world-map-popover-empty { + color: var(--text-muted); + font-style: italic; } @media (max-width: 960px) { diff --git a/src/build.ts b/src/build.ts index 131d3a5..4c2f2f7 100644 --- a/src/build.ts +++ b/src/build.ts @@ -21,11 +21,12 @@ async function writeHealthJson(version: string): Promise { // text/plain with nosniff so browsers refuse @import of these URLs; we // fetch + concatenate at build time and write the result to public/styles.css. // Same pattern + tag as provider-console / council-console. -const UI_LIB_TAG = "v0.3.1"; +const UI_LIB_TAG = "v0.3.2"; const UI_LIB_CSS_FILES = [ "tokens/tokens.css", "base-styles/base-styles.css", "nav/nav.css", + "world-map/world-map.css", ]; async function buildStyles(): Promise { diff --git a/src/lib/geo_test.ts b/src/lib/geo_test.ts deleted file mode 100644 index 4455a06..0000000 --- a/src/lib/geo_test.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { assertEquals } from "@std/assert"; -import { - COUNTRIES, - getCountryName, - projectCountry, - sanitizeSvgPath, -} from "./world-map.ts"; - -Deno.test("getCountryName returns full name for known code", () => { - assertEquals(getCountryName("US"), "United States"); - assertEquals(getCountryName("AR"), "Argentina"); - assertEquals(getCountryName("GB"), "United Kingdom"); -}); - -Deno.test("getCountryName returns code for unknown country", () => { - assertEquals(getCountryName("ZZ"), "ZZ"); -}); - -Deno.test("all country coords have valid lon/lat ranges", () => { - for (const [code, c] of Object.entries(COUNTRIES)) { - if (c.lon < -180 || c.lon > 180 || c.lat < -90 || c.lat > 90) { - throw new Error(`${code} out of bounds: (${c.lon}, ${c.lat})`); - } - } -}); - -Deno.test("projectCountry returns projected coordinates within SVG viewBox", () => { - const result = projectCountry("US", 0, 0); - assertEquals(result !== null, true); - if (result) { - // SVG viewBox: 30.767 241.591 784.077 458.627 - assertEquals(result.x > 30 && result.x < 815, true); - assertEquals(result.y > 241 && result.y < 700, true); - } -}); - -Deno.test("projectCountry returns null for unknown code", () => { - assertEquals(projectCountry("ZZ", 0, 0), null); -}); - -Deno.test("sanitizeSvgPath allows valid path commands", () => { - const valid = "M100.5,200.3 L300,400 Z"; - assertEquals(sanitizeSvgPath(valid), valid); -}); - -Deno.test("sanitizeSvgPath strips script injection", () => { - const malicious = 'M0,0" />'; - const result = sanitizeSvgPath(malicious); - assertEquals(result.includes("<"), false); - assertEquals(result.includes(">"), false); - assertEquals(result.includes('"'), false); -}); - -Deno.test("sanitizeSvgPath strips event handlers", () => { - const malicious = "M0,0 onmouseover=alert(1)"; - const result = sanitizeSvgPath(malicious); - // Only valid chars remain: M0,0 omousovelert1 - assertEquals(result.includes("="), false); - assertEquals(result.includes("("), false); - assertEquals(result.includes(")"), false); -}); diff --git a/src/lib/world-map.ts b/src/lib/world-map.ts deleted file mode 100644 index 6c49290..0000000 --- a/src/lib/world-map.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * World map renderer using a static SVG map. - * Uses simple-world-map (CC BY-SA 3.0, Al MacDonald / Fritz Lekschas) - * with ISO 3166-1 country IDs on each path. - * - * The SVG is served as a static asset from public/world-map.svg. - * Country coordinates for marker placement use the same hardcoded - * centroids as before, projected via equirectangular transform into - * the SVG's coordinate space. - */ - -// Only valid SVG path commands and numeric values. -const SVG_PATH_VALID = /[^MmLlHhVvCcSsQqTtAaZz0-9eE.,\s\-+]/g; - -/** - * Sanitize an SVG path `d` attribute by removing any characters - * that are not valid path commands, digits, or separators. - */ -export function sanitizeSvgPath(d: string): string { - return d.replace(SVG_PATH_VALID, ""); -} - -// Original SVG viewBox: 30.767 241.591 784.077 458.627 -const SVG_VB_X = 30.767; -const SVG_VB_Y = 241.591; -const SVG_VB_W = 784.077; -const SVG_VB_H = 458.627; - -/** - * Fetch the world map SVG from the local static asset. - * Returns the raw SVG string with all country paths. - */ -export async function fetchWorldSvg(): Promise { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), 10_000); - try { - const res = await fetch("/world-map.svg", { signal: controller.signal }); - if (!res.ok) throw new Error(`Failed to load map: ${res.status}`); - return await res.text(); - } finally { - clearTimeout(timer); - } -} - -/** - * Country coordinates (centroids, lon/lat). - * Projected at render time into the SVG's coordinate space. - */ -export const COUNTRIES: Record< - string, - { lon: number; lat: number; name: string } -> = { - // Americas - US: { lon: -98, lat: 39, name: "United States" }, - CA: { lon: -106, lat: 56, name: "Canada" }, - MX: { lon: -102, lat: 23, name: "Mexico" }, - BR: { lon: -51, lat: -14, name: "Brazil" }, - AR: { lon: -64, lat: -34, name: "Argentina" }, - CL: { lon: -71, lat: -35, name: "Chile" }, - CO: { lon: -74, lat: 4, name: "Colombia" }, - PE: { lon: -76, lat: -10, name: "Peru" }, - UY: { lon: -56, lat: -33, name: "Uruguay" }, - PY: { lon: -58, lat: -23, name: "Paraguay" }, - EC: { lon: -78, lat: -2, name: "Ecuador" }, - VE: { lon: -66, lat: 8, name: "Venezuela" }, - CR: { lon: -84, lat: 10, name: "Costa Rica" }, - PA: { lon: -80, lat: 9, name: "Panama" }, - - // Europe - GB: { lon: -2, lat: 54, name: "United Kingdom" }, - DE: { lon: 10, lat: 51, name: "Germany" }, - FR: { lon: 2, lat: 46, name: "France" }, - ES: { lon: -4, lat: 40, name: "Spain" }, - IT: { lon: 12, lat: 42, name: "Italy" }, - CH: { lon: 8, lat: 47, name: "Switzerland" }, - NL: { lon: 5, lat: 52, name: "Netherlands" }, - SE: { lon: 18, lat: 60, name: "Sweden" }, - NO: { lon: 10, lat: 62, name: "Norway" }, - FI: { lon: 26, lat: 64, name: "Finland" }, - PT: { lon: -8, lat: 39, name: "Portugal" }, - IE: { lon: -8, lat: 53, name: "Ireland" }, - PL: { lon: 20, lat: 52, name: "Poland" }, - AT: { lon: 14, lat: 47, name: "Austria" }, - BE: { lon: 4, lat: 51, name: "Belgium" }, - UA: { lon: 32, lat: 49, name: "Ukraine" }, - RU: { lon: 40, lat: 56, name: "Russia" }, - - // Africa - NG: { lon: 8, lat: 10, name: "Nigeria" }, - ZA: { lon: 25, lat: -29, name: "South Africa" }, - KE: { lon: 38, lat: 0, name: "Kenya" }, - EG: { lon: 30, lat: 27, name: "Egypt" }, - MA: { lon: -5, lat: 32, name: "Morocco" }, - GH: { lon: -2, lat: 8, name: "Ghana" }, - - // Middle East - AE: { lon: 54, lat: 24, name: "UAE" }, - SA: { lon: 45, lat: 24, name: "Saudi Arabia" }, - IL: { lon: 35, lat: 31, name: "Israel" }, - TR: { lon: 32, lat: 39, name: "Turkey" }, - - // Asia - IN: { lon: 78, lat: 21, name: "India" }, - CN: { lon: 104, lat: 35, name: "China" }, - JP: { lon: 138, lat: 36, name: "Japan" }, - KR: { lon: 128, lat: 36, name: "South Korea" }, - SG: { lon: 104, lat: 1, name: "Singapore" }, - TH: { lon: 101, lat: 15, name: "Thailand" }, - VN: { lon: 108, lat: 14, name: "Vietnam" }, - ID: { lon: 113, lat: -1, name: "Indonesia" }, - PH: { lon: 122, lat: 13, name: "Philippines" }, - MY: { lon: 102, lat: 4, name: "Malaysia" }, - - // Oceania - AU: { lon: 133, lat: -25, name: "Australia" }, - NZ: { lon: 174, lat: -41, name: "New Zealand" }, -}; - -export function getCountryName(code: string): string { - return COUNTRIES[code]?.name ?? code; -} - -/** - * Project a country's centroid into the SVG's coordinate space. - * Uses the same equirectangular projection as the SVG map. - */ -export function projectCountry( - code: string, - _width: number, - _height: number, -): { x: number; y: number } | null { - const c = COUNTRIES[code.toUpperCase()]; - if (!c) return null; - - // Equirectangular: map lon/lat to the SVG viewBox coordinate space. - // The SVG's viewBox maps roughly to -180..180 lon, -60..85 lat - // (standard world map cropping). - const x = SVG_VB_X + ((c.lon + 180) / 360) * SVG_VB_W; - const y = SVG_VB_Y + ((85 - c.lat) / 145) * SVG_VB_H; - - return { x, y }; -} diff --git a/src/views/world-map.ts b/src/views/world-map.ts index 888ab34..23beb50 100644 --- a/src/views/world-map.ts +++ b/src/views/world-map.ts @@ -1,29 +1,40 @@ -import type { CouncilTopologyEntry } from "../lib/network-events.ts"; import { COUNTRIES, - fetchWorldSvg, - getCountryName, - projectCountry, - sanitizeSvgPath, -} from "../lib/world-map.ts"; + renderWorldMap, + type WorldMapHandle, +} from "@moonlight/ui/world-map"; +import type { CouncilTopologyEntry } from "../lib/network-events.ts"; /** - * §5 — World map. Bottom strip showing jurisdictions of registered - * councils as small markers on the SVG world atlas. Unknown / unresolvable - * jurisdiction codes are silently dropped; the layout doesn't depend on - * complete coverage. + * §5 — World map. Bottom strip showing council jurisdictions. + * + * Visual states layered on top of the shared @moonlight/ui/world-map + * component (which itself knows nothing about councils): + * - `selected`: union of all council jurisdictions in the topology. + * - `hovered`: the country currently under the pointer. + * - `reachable`: every other country sharing a council with `hovered`. + * - `dimmed`: every country not in the active focus set (faded out). + * + * Hover handling lives entirely on this side; the map component only + * fires `onHover(code | null)` and exposes `setSlot()` to apply our + * dashboard-specific CSS classes. A small popover next to the cursor + * lists every council whose jurisdictions include the hovered country + * (name + provider count), or "No councils" when none. */ -const SVG_NS = "http://www.w3.org/2000/svg"; +const HOVERED_SLOT = "hovered"; +const REACHABLE_SLOT = "reachable"; +const DIMMED_SLOT = "dimmed"; export class WorldMap { private root: HTMLElement; private mapHost: HTMLDivElement; private status: HTMLElement; - private pendingTopology: CouncilTopologyEntry[] = []; - private svgRoot: SVGSVGElement | null = null; - private markerLayer: SVGGElement | null = null; - private loading = false; + private popover: HTMLDivElement; + private handle: WorldMapHandle | null = null; + private pendingMount: Promise | null = null; + private councils: CouncilTopologyEntry[] = []; + private allCodes: string[] = []; private failed = false; constructor() { @@ -43,7 +54,12 @@ export class WorldMap { header.append(title, status); this.mapHost = document.createElement("div"); - this.mapHost.className = "world-map-host"; + this.mapHost.className = "world-map-section-host"; + + this.popover = document.createElement("div"); + this.popover.className = "world-map-popover"; + this.popover.hidden = true; + this.mapHost.appendChild(this.popover); this.root.append(header, this.mapHost); } @@ -53,125 +69,154 @@ export class WorldMap { } render(topology: CouncilTopologyEntry[]): void { - this.pendingTopology = topology; + this.councils = topology; + this.allCodes = Array.from( + topology.reduce((acc, c) => { + for (const code of c.jurisdictions) { + const upper = code.toUpperCase(); + if (COUNTRIES[upper]) acc.add(upper); + } + return acc; + }, new Set()), + ); + if (this.failed) { this.status.textContent = "map unavailable"; return; } - if (!this.svgRoot && !this.loading) { - this.loadSvg(); + if (this.handle) { + this.handle.setSelected(this.allCodes); + this.updateStatus(); return; } - if (this.svgRoot && this.markerLayer) this.repaintMarkers(); + if (this.pendingMount) return; + + this.pendingMount = renderWorldMap({ + selected: this.allCodes, + svgUrl: "/world-map.svg", + onHover: (code) => this.handleHover(code), + }) + .then((handle) => { + this.handle = handle; + this.mapHost.insertBefore(handle.element, this.popover); + handle.setSelected(this.allCodes); + this.updateStatus(); + this.mapHost.addEventListener( + "mousemove", + (ev) => this.positionPopover(ev), + ); + }) + .catch((err) => { + this.failed = true; + this.status.textContent = "map unavailable"; + console.warn("World map failed to load", err); + }) + .finally(() => { + this.pendingMount = null; + }); } - // ── internals ────────────────────────────────────────────────────── - - private async loadSvg(): Promise { - this.loading = true; - try { - const raw = await fetchWorldSvg(); - const parsed = new DOMParser().parseFromString(raw, "image/svg+xml"); - const svgEl = parsed.documentElement; - if (svgEl.nodeName !== "svg") { - throw new Error("world-map.svg did not parse as "); - } - // Re-create an SVG in our namespace; copying nodes wholesale fails - // some browsers' namespace checks when later querying. - const svg = document.createElementNS( - SVG_NS, - "svg", - ) as SVGSVGElement; - const viewBox = svgEl.getAttribute("viewBox") ?? - "30.767 241.591 784.077 458.627"; - svg.setAttribute("viewBox", viewBox); - svg.setAttribute("preserveAspectRatio", "xMidYMid meet"); - svg.classList.add("world-map-svg"); - - const paths = svgEl.querySelectorAll("path"); - const land = document.createElementNS(SVG_NS, "g") as SVGGElement; - land.setAttribute("class", "world-map-land"); - for (const path of Array.from(paths)) { - const d = sanitizeSvgPath(path.getAttribute("d") ?? ""); - if (!d) continue; - const id = path.getAttribute("id"); - const newPath = document.createElementNS(SVG_NS, "path"); - newPath.setAttribute("d", d); - newPath.setAttribute("fill", "#f1f3f5"); - newPath.setAttribute("stroke", "#dee2e6"); - newPath.setAttribute("stroke-width", "0.5"); - if (id) newPath.setAttribute("data-iso", id.toUpperCase()); - land.appendChild(newPath); - } - - const markers = document.createElementNS(SVG_NS, "g") as SVGGElement; - markers.setAttribute("class", "world-map-markers"); + private updateStatus(): void { + this.status.textContent = this.allCodes.length === 0 + ? "no jurisdictions" + : `${this.allCodes.length} jurisdiction${ + this.allCodes.length === 1 ? "" : "s" + }`; + } - svg.append(land, markers); - this.mapHost.textContent = ""; - this.mapHost.appendChild(svg); + private handleHover(code: string | null): void { + if (!this.handle) return; + if (code === null) { + this.handle.setSlot(HOVERED_SLOT, []); + this.handle.setSlot(REACHABLE_SLOT, []); + this.handle.setSlot(DIMMED_SLOT, []); + this.popover.hidden = true; + return; + } - this.svgRoot = svg; - this.markerLayer = markers; - this.repaintMarkers(); - this.status.textContent = "ready"; - } catch (err) { - this.failed = true; - console.warn("World map failed to load", err); - this.status.textContent = "map unavailable"; - this.mapHost.textContent = ""; - } finally { - this.loading = false; + // Councils whose jurisdictions include the hovered country. + const hoveredCouncils = this.councils.filter((c) => + c.jurisdictions.some((j) => j.toUpperCase() === code) + ); + + if (hoveredCouncils.length === 0) { + // No-council country: just dim everything else, popover says so. + const dim = this.allCodes.filter((c) => c !== code); + this.handle.setSlot(HOVERED_SLOT, [code]); + this.handle.setSlot(REACHABLE_SLOT, []); + this.handle.setSlot(DIMMED_SLOT, dim); + this.renderPopoverEmpty(code); + return; } - } - private repaintMarkers(): void { - if (!this.markerLayer) return; - this.markerLayer.textContent = ""; - - const seen = new Map(); - for (const c of this.pendingTopology) { - for (const code of c.jurisdictions) { - const upper = code.toUpperCase(); - if (!COUNTRIES[upper]) continue; - const bucket = seen.get(upper) ?? []; - bucket.push(c.name ?? c.id); - seen.set(upper, bucket); + // Reachable = union of jurisdictions across all councils touching `code`, + // minus the hovered country itself. + const reachable = new Set(); + for (const c of hoveredCouncils) { + for (const j of c.jurisdictions) { + const upper = j.toUpperCase(); + if (upper !== code && COUNTRIES[upper]) reachable.add(upper); } } + const focus = new Set([code, ...reachable]); + const dim = this.allCodes.filter((c) => !focus.has(c)); - if (seen.size === 0) { - const hint = document.createElementNS(SVG_NS, "text"); - hint.setAttribute("x", "400"); - hint.setAttribute("y", "470"); - hint.setAttribute("text-anchor", "middle"); - hint.classList.add("world-map-hint"); - hint.textContent = "No council jurisdictions resolved yet."; - this.markerLayer.appendChild(hint); - this.status.textContent = "no jurisdictions"; - return; - } + this.handle.setSlot(HOVERED_SLOT, [code]); + this.handle.setSlot(REACHABLE_SLOT, Array.from(reachable)); + this.handle.setSlot(DIMMED_SLOT, dim); + this.renderPopoverCouncils(code, hoveredCouncils); + } - for (const [code, councils] of seen) { - const projected = projectCountry(code, 0, 0); - if (!projected) continue; - const group = document.createElementNS(SVG_NS, "g"); - group.classList.add("world-map-marker"); - group.dataset.iso = code; - - const dot = document.createElementNS(SVG_NS, "circle"); - dot.setAttribute("cx", String(projected.x)); - dot.setAttribute("cy", String(projected.y)); - dot.setAttribute("r", "6"); - dot.setAttribute("fill", "#e8590c"); - dot.setAttribute("stroke", "#ffe8cc"); - dot.setAttribute("stroke-width", "2"); - - const title = document.createElementNS(SVG_NS, "title"); - title.textContent = `${getCountryName(code)} — ${councils.join(", ")}`; - group.append(dot, title); - this.markerLayer.appendChild(group); + private renderPopoverCouncils( + code: string, + councils: CouncilTopologyEntry[], + ): void { + this.popover.textContent = ""; + const header = document.createElement("div"); + header.className = "world-map-popover-header"; + header.textContent = `${COUNTRIES[code]?.name ?? code} (${code})`; + this.popover.appendChild(header); + + const list = document.createElement("ul"); + list.className = "world-map-popover-list"; + for (const c of councils) { + const li = document.createElement("li"); + const name = document.createElement("span"); + name.className = "world-map-popover-name"; + name.textContent = c.name ?? c.id.slice(0, 12); + const meta = document.createElement("span"); + meta.className = "world-map-popover-meta"; + const n = c.providers.length; + meta.textContent = `${n} PP${n === 1 ? "" : "s"}`; + li.append(name, meta); + list.appendChild(li); } - this.status.textContent = `${seen.size} jurisdictions`; + this.popover.appendChild(list); + this.popover.hidden = false; + } + + private renderPopoverEmpty(code: string): void { + this.popover.textContent = ""; + const header = document.createElement("div"); + header.className = "world-map-popover-header"; + header.textContent = `${COUNTRIES[code]?.name ?? code} (${code})`; + const body = document.createElement("div"); + body.className = "world-map-popover-empty"; + body.textContent = "No councils"; + this.popover.append(header, body); + this.popover.hidden = false; + } + + private positionPopover(ev: MouseEvent): void { + if (this.popover.hidden) return; + const hostRect = this.mapHost.getBoundingClientRect(); + const x = ev.clientX - hostRect.left + 14; + const y = ev.clientY - hostRect.top + 14; + // Keep the popover inside the host bounds; nudge left/up if needed. + const popRect = this.popover.getBoundingClientRect(); + const maxX = hostRect.width - popRect.width - 8; + const maxY = hostRect.height - popRect.height - 8; + this.popover.style.left = `${Math.max(0, Math.min(x, maxX))}px`; + this.popover.style.top = `${Math.max(0, Math.min(y, maxY))}px`; } } From c10a400239be22303ecaa1b8e7bade7038351abe Mon Sep 17 00:00:00 2001 From: Gorka Date: Thu, 21 May 2026 08:50:15 -0300 Subject: [PATCH 5/8] =?UTF-8?q?feat(activity-feed):=20card=20TTL=208s=20?= =?UTF-8?q?=E2=86=92=205=20min,=20max-visible=205=20=E2=86=92=2010?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cards now stay long enough to read in the new trio layout instead of flicking past in a few seconds, and the visible cap grew to keep recent history available across the longer TTL. --- src/views/activity-feed.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/views/activity-feed.ts b/src/views/activity-feed.ts index 8645577..4cebf1e 100644 --- a/src/views/activity-feed.ts +++ b/src/views/activity-feed.ts @@ -9,8 +9,8 @@ import type { WsStatus } from "../lib/ws-client.ts"; * the sketch palette. */ -const CARD_TTL_MS = 8_000; -const MAX_VISIBLE = 5; +const CARD_TTL_MS = 5 * 60 * 1000; +const MAX_VISIBLE = 10; const KIND_GLYPH: Record = { council_formed: "★", From 36119f333cd968710482fdd0fa62ec3852224763 Mon Sep 17 00:00:00 2001 From: Gorka Date: Thu, 21 May 2026 08:52:38 -0300 Subject: [PATCH 6/8] refactor(layout): drop topology, add Country + Provider details drilldown in a 3x3 grid The topology view added little beyond what the world map + per-country council list already convey, so it's removed entirely. New layout below the counter strip is a single 3x3 grid: +-----+-----+--------------+ | map | map | countries | <- CountryDetails (click a country) +-----+-----+--------------+ | map | map | councils | <- CouncilDetails +-----+-----+--------------+ | events events| providers | <- ActivityFeed + ProviderDetails +--------------+-----------+ Drilldown chain wired in app.ts: click country on map -> CountryDetails lists the councils there -> click a council in that list -> CouncilDetails populates -> click a PP in CouncilDetails -> ProviderDetails populates Each grid cell scrolls vertically when content overflows its row height; section headers stay put. The Country / Council / Provider panels get `overflow-y: auto` on their inner content lists; the activity feed list already had it. WorldMap exposes `setOnCountryClick` + `setSelectedCountry` so the clicked-country highlight is persistent (independent of the hover state that drives the popover + reachable highlight). CouncilDetails exposes `setOnPpClick`. CountryDetails exposes `setOnCouncilClick`. App.ts wires them together in one place. --- src/app-styles.css | 365 +++++++++++++++++++++++++++------- src/app.ts | 92 ++++++--- src/views/council-details.ts | 14 +- src/views/country-details.ts | 130 ++++++++++++ src/views/provider-details.ts | 156 +++++++++++++++ src/views/topology.ts | 348 -------------------------------- src/views/world-map.ts | 31 +++ 7 files changed, 692 insertions(+), 444 deletions(-) create mode 100644 src/views/country-details.ts create mode 100644 src/views/provider-details.ts delete mode 100644 src/views/topology.ts diff --git a/src/app-styles.css b/src/app-styles.css index e296dc7..e922311 100644 --- a/src/app-styles.css +++ b/src/app-styles.css @@ -56,8 +56,48 @@ main.container { gap: 1rem; } -.row.hero { - grid-template-columns: minmax(0, 1fr) 320px; +.row.detail-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + grid-template-rows: repeat(3, minmax(200px, 240px)); + grid-template-areas: + "map map countries" + "map map councils" + "events events providers"; +} + +/* `min-height: 0` lets the grid cell win over each section's intrinsic + content height, and `overflow: hidden` clips children so the inner + scroll regions can take over (set on the content lists below). */ +.row.detail-grid > .section { + min-height: 0; + overflow: hidden; +} +.row.detail-grid > .world-map { + grid-area: map; +} +.row.detail-grid > .country-details { + grid-area: countries; +} +.row.detail-grid > .council-details { + grid-area: councils; +} +.row.detail-grid > .provider-details { + grid-area: providers; +} +.row.detail-grid > .activity-feed { + grid-area: events; +} + +/* Each panel's content area scrolls vertically; the header stays put. */ +.country-details-panel, +.council-details-panel, +.provider-details-panel { + flex: 1; + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 0; } .row.trends { @@ -106,69 +146,18 @@ main.container { font-variant-numeric: tabular-nums; } -/* §2 — Topology ───────────────────────────────────────────────────── */ +/* §3 — Activity feed ──────────────────────────────────────────────── */ -.topology { +.activity-feed, +.council-details, +.provider-details { display: flex; flex-direction: column; - min-height: 540px; - padding: 0.5rem; -} - -.topology-svg { - width: 100%; - height: 100%; - min-height: 520px; -} - -.topology-label-center { - font-size: 0.875rem; - font-weight: 700; - letter-spacing: 0.08em; - fill: #1a1d27; -} - -.topology-label-council { - font-size: 0.75rem; - font-weight: 600; - fill: var(--text); -} - -.topology-label-jurisdiction { - font-size: 0.65rem; - fill: var(--text-muted); -} - -.topology-hint { - font-size: 0.875rem; - fill: var(--text-muted); - font-style: italic; -} - -.topology-council circle { - transition: filter 120ms ease-out, stroke-width 120ms ease-out; -} - -.topology-council:hover circle { - filter: brightness(1.15); - stroke-width: 3; -} - -.topology-council.selected circle { - stroke: var(--primary); - stroke-width: 3.5; -} - -.topology-pulse { - pointer-events: none; + min-height: 420px; + padding: 0.75rem; } -/* §2 — Activity feed ──────────────────────────────────────────────── */ - .activity-feed { - display: flex; - flex-direction: column; - min-height: 540px; padding: 0.75rem; } @@ -212,10 +201,11 @@ main.container { .activity-feed-list { flex: 1; + min-height: 0; display: flex; flex-direction: column; gap: 0.5rem; - overflow: hidden; + overflow-y: auto; } .activity-card { @@ -418,20 +408,29 @@ main.container { display: flex; flex-direction: column; gap: 0.25rem; - font-family: var(--font-mono); - font-size: 0.7rem; - color: var(--text); } -.council-details-pp-list li { +.council-details-pp-button { + width: 100%; background: rgba(255, 255, 255, 0.03); + border: 1px solid transparent; border-radius: 6px; padding: 0.25rem 0.5rem; + color: var(--text); + font-family: var(--font-mono); + font-size: 0.7rem; + text-align: left; + cursor: pointer; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.council-details-pp-button:hover { + background: rgba(255, 255, 255, 0.06); + border-color: var(--border); +} + /* §4 — Sparklines ─────────────────────────────────────────────────── */ .sparklines { @@ -616,6 +615,226 @@ main.container { fill: rgba(139, 143, 163, 0.18); } +/* Persistent "you clicked this" highlight — kept after the pointer leaves + so the country-details side panel has a visible anchor. */ +.world-map-svg .world-map-country.active-country, +.world-map-svg .world-map-country.active-country path { + fill: var(--primary); + stroke: var(--primary-hover); + stroke-width: 1; +} + +/* §5 — Country details side panel ────────────────────────────────── */ + +.country-details { + padding: 0.875rem 1.125rem; + display: flex; + flex-direction: column; +} + +.country-details-hint { + font-size: 0.8125rem; + color: var(--text-muted); + font-style: italic; +} + +.country-details-hint.hidden, +.country-details-panel.hidden { + display: none; +} + +.country-details-header { + display: flex; + gap: 0.625rem; + align-items: baseline; + margin-bottom: 0.625rem; +} + +.country-details-title { + margin: 0; + font-size: 1.0625rem; + font-weight: 700; + color: var(--text); +} + +.country-details-code { + font-size: 0.7rem; + color: var(--text-muted); + background: rgba(139, 143, 163, 0.18); + padding: 0.125rem 0.5rem; + border-radius: 999px; + font-family: var(--font-mono); +} + +.country-details-empty { + font-size: 0.8125rem; + color: var(--text-muted); + font-style: italic; +} + +.country-details-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; + overflow-y: auto; +} + +.country-details-council { + width: 100%; + background: rgba(255, 255, 255, 0.03); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0.5rem 0.625rem; + display: flex; + flex-direction: column; + gap: 0.125rem; + text-align: left; + color: var(--text); + cursor: pointer; +} + +.country-details-council:hover { + background: rgba(255, 255, 255, 0.07); + border-color: var(--primary); +} + +.country-details-council-name { + font-size: 0.8125rem; + font-weight: 700; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.country-details-council-meta { + font-size: 0.7rem; + color: var(--text-muted); + font-variant-numeric: tabular-nums; +} + +.country-details-council-juris { + font-size: 0.7rem; + color: var(--text-muted); + font-family: var(--font-mono); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* §3 — Provider details column ────────────────────────────────────── */ + +.provider-details-hint { + font-size: 0.8125rem; + color: var(--text-muted); + font-style: italic; +} + +.provider-details-hint.hidden, +.provider-details-panel.hidden { + display: none; +} + +.provider-details-header { + margin-bottom: 0.625rem; +} + +.provider-details-title { + margin: 0; + font-size: 1.0625rem; + font-weight: 700; + color: var(--text); + word-break: break-all; +} + +.provider-details-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.625rem; + margin-bottom: 0.75rem; +} + +.provider-details-kv { + display: flex; + flex-direction: column; + gap: 0.125rem; + background: rgba(255, 255, 255, 0.03); + border-radius: 6px; + padding: 0.5rem 0.625rem; + border: 1px solid var(--border); +} + +.provider-details-kv-label { + font-size: 0.625rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +.provider-details-kv-value { + font-size: 1.0625rem; + font-weight: 700; + color: var(--text); + font-variant-numeric: tabular-nums; +} + +.provider-details-subheader { + margin: 0.5rem 0 0.25rem; + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +.provider-details-pubkey { + font-family: var(--font-mono); + font-size: 0.6875rem; + color: var(--text); + word-break: break-all; + background: rgba(255, 255, 255, 0.03); + border-radius: 6px; + padding: 0.375rem 0.5rem; +} + +.provider-details-council-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.provider-details-council-list li { + background: rgba(255, 255, 255, 0.03); + border-radius: 6px; + padding: 0.375rem 0.5rem; + display: flex; + justify-content: space-between; + gap: 0.5rem; + font-size: 0.75rem; + overflow: hidden; +} + +.provider-details-council-name { + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.provider-details-council-juris { + color: var(--text-muted); + font-family: var(--font-mono); + font-size: 0.65rem; + flex-shrink: 0; +} + /* Popover anchored to the cursor inside the §5 host. */ .world-map-popover { position: absolute; @@ -671,7 +890,16 @@ main.container { } @media (max-width: 960px) { - .row.hero, + .row.detail-grid { + grid-template-columns: 1fr; + grid-template-rows: auto; + grid-template-areas: + "map" + "countries" + "councils" + "events" + "providers"; + } .row.trends { grid-template-columns: 1fr; } @@ -681,8 +909,9 @@ main.container { .sparklines { grid-template-columns: 1fr; } - .topology, - .activity-feed { - min-height: 360px; + .activity-feed, + .council-details, + .provider-details { + min-height: 320px; } } diff --git a/src/app.ts b/src/app.ts index c220755..b85a210 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,37 +1,40 @@ /** * Network-dashboard SPA entry. Single page, no router, no auth. * - * Renders the 5-section layout per the locked design sketch and pipes - * frames from the public WebSocket into each section. + * Layout (top to bottom): + * 1. Counter strip + * 2. World map + Country details (2/3 + 1/3) + * 3. Detail trio — Events + Council details + Provider details (1/3 each) + * 4. Sparklines + Asset breakdown * - * §1 Counter strip - * §2 Topology + Activity feed (side-by-side) - * §3 Council details panel (collapsed until topology click) - * §4 Sparklines + Asset breakdown (side-by-side) - * §5 World map + * Drilldown chain: map country click → CountryDetails lists councils → + * council click → CouncilDetails populates → PP click → ProviderDetails + * populates. */ import { renderNav } from "@moonlight/ui/nav"; import { pageLayout } from "@moonlight/ui/layout"; import { NETWORK_DASHBOARD_PLATFORM_URL } from "./lib/config.ts"; import { connectNetworkPlatform } from "./lib/ws-client.ts"; import { CounterStrip } from "./views/counter-strip.ts"; -import { Topology } from "./views/topology.ts"; import { ActivityFeed } from "./views/activity-feed.ts"; import { CouncilDetails } from "./views/council-details.ts"; import { SparklineGroup } from "./views/sparklines.ts"; import { AssetBreakdown } from "./views/asset-breakdown.ts"; import { WorldMap } from "./views/world-map.ts"; +import { CountryDetails } from "./views/country-details.ts"; +import { ProviderDetails } from "./views/provider-details.ts"; declare const __APP_VERSION__: string; function renderShell(): { counters: CounterStrip; - topology: Topology; feed: ActivityFeed; details: CouncilDetails; sparklines: SparklineGroup; assets: AssetBreakdown; worldMap: WorldMap; + countryDetails: CountryDetails; + providerDetails: ProviderDetails; } { const app = document.getElementById("app"); if (!app) throw new Error("#app root not found"); @@ -42,13 +45,26 @@ function renderShell(): { const counters = new CounterStrip(); - const heroRow = document.createElement("div"); - heroRow.className = "row hero"; - const topology = new Topology(); - const feed = new ActivityFeed(); - heroRow.append(topology.element(), feed.element()); - + // 3×3 grid: + // row 1: map map country-details + // row 2: map map council-details + // row 3: feed feed provider-details + // Positions are CSS-driven via grid-template-areas; DOM order is just + // for accessibility / focus traversal. + const grid = document.createElement("div"); + grid.className = "row detail-grid"; + const worldMap = new WorldMap(); + const countryDetails = new CountryDetails(); const details = new CouncilDetails(); + const providerDetails = new ProviderDetails(); + const feed = new ActivityFeed(); + grid.append( + worldMap.element(), + countryDetails.element(), + details.element(), + feed.element(), + providerDetails.element(), + ); const trendsRow = document.createElement("div"); trendsRow.className = "row trends"; @@ -56,14 +72,10 @@ function renderShell(): { const assets = new AssetBreakdown(); trendsRow.append(sparklines.element(), assets.element()); - const worldMap = new WorldMap(); - dashboard.append( counters.element(), - heroRow, - details.element(), + grid, trendsRow, - worldMap.element(), ); const nav = renderNav({ @@ -71,16 +83,41 @@ function renderShell(): { version: __APP_VERSION__, }); app.appendChild(pageLayout(nav, dashboard)); - return { counters, topology, feed, details, sparklines, assets, worldMap }; + return { + counters, + feed, + details, + sparklines, + assets, + worldMap, + countryDetails, + providerDetails, + }; } function bootstrap() { - const { counters, topology, feed, details, sparklines, assets, worldMap } = - renderShell(); + const { + counters, + feed, + details, + sparklines, + assets, + worldMap, + countryDetails, + providerDetails, + } = renderShell(); - topology.setCouncilClickHandler((councilId) => { + // Drilldown wiring: country → council → PP. + worldMap.setOnCountryClick((countryCode) => { + countryDetails.select(countryCode); + worldMap.setSelectedCountry(countryCode); + }); + countryDetails.setOnCouncilClick((councilId) => { details.select(councilId); - topology.setSelectedCouncil(councilId); + providerDetails.clear(); + }); + details.setOnPpClick((pubKey) => { + providerDetails.select(pubKey); }); if (!NETWORK_DASHBOARD_PLATFORM_URL) { @@ -92,17 +129,18 @@ function bootstrap() { onStatusChange: (status) => feed.setStatus(status), onSnapshot: (frame) => { counters.render(frame.counters); - topology.render(frame.topology); feed.seed(frame.recent); details.setTopology(frame.topology); details.setRollingMetrics(frame.councilRolling); sparklines.render(frame.sparklines); assets.render(frame.assetBreakdown); worldMap.render(frame.topology); + countryDetails.setTopology(frame.topology); + providerDetails.setTopology(frame.topology); + providerDetails.setRecent(frame.recent); }, onEvent: (event) => { feed.append(event); - topology.pulse(event); counters.bumpFromLiveEvent(); }, }); diff --git a/src/views/council-details.ts b/src/views/council-details.ts index f22cfb9..80bd67b 100644 --- a/src/views/council-details.ts +++ b/src/views/council-details.ts @@ -29,6 +29,8 @@ const EMPTY_METRICS: CouncilRollingMetrics = { settlementVolumeStroops: "0", }; +export type PpClickHandler = (publicKey: string) => void; + export class CouncilDetails { private root: HTMLElement; private hint: HTMLElement; @@ -36,6 +38,7 @@ export class CouncilDetails { private councilsById = new Map(); private rolling: Record = {}; private selectedCouncilId: string | null = null; + private onPpClick: PpClickHandler | null = null; constructor() { this.root = document.createElement("section"); @@ -72,6 +75,10 @@ export class CouncilDetails { if (this.selectedCouncilId) this.paint(); } + setOnPpClick(handler: PpClickHandler | null): void { + this.onPpClick = handler; + } + select(councilId: string): void { if (!this.councilsById.has(councilId)) return; this.selectedCouncilId = councilId; @@ -178,7 +185,12 @@ export class CouncilDetails { ppList.className = "council-details-pp-list"; for (const pp of council.providers) { const li = document.createElement("li"); - li.textContent = pp.publicKey; + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "council-details-pp-button"; + btn.textContent = pp.label?.trim() || pp.publicKey; + btn.addEventListener("click", () => this.onPpClick?.(pp.publicKey)); + li.appendChild(btn); ppList.appendChild(li); } this.panel.appendChild(ppList); diff --git a/src/views/country-details.ts b/src/views/country-details.ts new file mode 100644 index 0000000..78309a9 --- /dev/null +++ b/src/views/country-details.ts @@ -0,0 +1,130 @@ +import { COUNTRIES } from "@moonlight/ui/world-map"; +import type { CouncilTopologyEntry } from "../lib/network-events.ts"; + +/** + * Right-rail panel paired with §5's world map. Clicking a country on the + * map updates this panel with the list of councils whose jurisdictions + * include that country: name, providers, channels, and the full + * jurisdictions list each council operates in. + * + * Defaults to a "click a country" hint while no country is selected. + */ +export type CouncilClickHandler = (councilId: string) => void; + +export class CountryDetails { + private root: HTMLElement; + private hint: HTMLElement; + private panel: HTMLElement; + private councils: CouncilTopologyEntry[] = []; + private selectedCountry: string | null = null; + private onCouncilClick: CouncilClickHandler | null = null; + + constructor() { + this.root = document.createElement("section"); + this.root.className = "section country-details"; + this.root.setAttribute("aria-label", "Country council details"); + + this.hint = document.createElement("div"); + this.hint.className = "country-details-hint"; + this.hint.textContent = "Click a country on the map to see its councils."; + + this.panel = document.createElement("div"); + this.panel.className = "country-details-panel hidden"; + + this.root.append(this.hint, this.panel); + } + + element(): HTMLElement { + return this.root; + } + + setTopology(topology: CouncilTopologyEntry[]): void { + this.councils = topology; + if (this.selectedCountry) this.paint(); + } + + setOnCouncilClick(handler: CouncilClickHandler | null): void { + this.onCouncilClick = handler; + } + + select(code: string): void { + this.selectedCountry = code.toUpperCase(); + this.paint(); + } + + clear(): void { + this.selectedCountry = null; + this.panel.classList.add("hidden"); + this.hint.classList.remove("hidden"); + this.panel.textContent = ""; + } + + private paint(): void { + if (!this.selectedCountry) return; + const code = this.selectedCountry; + const match = this.councils.filter((c) => + c.jurisdictions.some((j) => j.toUpperCase() === code) + ); + + this.panel.textContent = ""; + this.panel.classList.remove("hidden"); + this.hint.classList.add("hidden"); + + const header = document.createElement("header"); + header.className = "country-details-header"; + const title = document.createElement("h2"); + title.className = "country-details-title"; + title.textContent = COUNTRIES[code]?.name ?? code; + const codeBadge = document.createElement("span"); + codeBadge.className = "country-details-code"; + codeBadge.textContent = code; + header.append(title, codeBadge); + this.panel.appendChild(header); + + if (match.length === 0) { + const empty = document.createElement("div"); + empty.className = "country-details-empty"; + empty.textContent = "No councils operate in this jurisdiction."; + this.panel.appendChild(empty); + return; + } + + const list = document.createElement("ul"); + list.className = "country-details-list"; + for (const c of match) { + const li = document.createElement("li"); + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "country-details-council"; + btn.addEventListener("click", () => this.onCouncilClick?.(c.id)); + + const name = document.createElement("div"); + name.className = "country-details-council-name"; + name.textContent = c.name ?? c.id.slice(0, 12); + btn.appendChild(name); + + const meta = document.createElement("div"); + meta.className = "country-details-council-meta"; + const ppCount = c.providers.length; + const chCount = c.channels.length; + const ppText = `${ppCount} PP${ppCount === 1 ? "" : "s"}`; + const chText = `${chCount} channel${chCount === 1 ? "" : "s"}`; + meta.textContent = `${ppText} · ${chText}`; + btn.appendChild(meta); + + if (c.jurisdictions.length > 1) { + const juris = document.createElement("div"); + juris.className = "country-details-council-juris"; + const others = c.jurisdictions + .map((j) => j.toUpperCase()) + .filter((j) => j !== code); + juris.textContent = `Also: ${others.join(", ")}`; + btn.appendChild(juris); + } + + li.appendChild(btn); + list.appendChild(li); + } + this.panel.appendChild(list); + } +} diff --git a/src/views/provider-details.ts b/src/views/provider-details.ts new file mode 100644 index 0000000..a9faee2 --- /dev/null +++ b/src/views/provider-details.ts @@ -0,0 +1,156 @@ +import { truncateAddress } from "../lib/dom.ts"; +import type { + CouncilTopologyEntry, + NetworkEvent, +} from "../lib/network-events.ts"; + +/** + * Provider info column in the §3 detail trio. Sibling of CountryDetails + * and CouncilDetails — collapsed-with-hint until a PP is selected, then + * shows that PP's full pubkey, label, the set of councils they're a + * member of, and a recent-bundle count derived from the activity ring + * buffer. + * + * Drilldown path: country click → country-details lists councils → + * council click → council-details lists PPs → PP click → this panel. + */ +export class ProviderDetails { + private root: HTMLElement; + private hint: HTMLElement; + private panel: HTMLElement; + private topology: CouncilTopologyEntry[] = []; + private recent: NetworkEvent[] = []; + private selectedPubKey: string | null = null; + + constructor() { + this.root = document.createElement("section"); + this.root.className = "section provider-details"; + this.root.setAttribute("aria-label", "Provider details"); + + this.hint = document.createElement("div"); + this.hint.className = "provider-details-hint"; + this.hint.textContent = "Click a PP to see details."; + + this.panel = document.createElement("div"); + this.panel.className = "provider-details-panel hidden"; + + this.root.append(this.hint, this.panel); + } + + element(): HTMLElement { + return this.root; + } + + setTopology(topology: CouncilTopologyEntry[]): void { + this.topology = topology; + if (this.selectedPubKey) this.paint(); + } + + setRecent(events: NetworkEvent[]): void { + this.recent = events; + if (this.selectedPubKey) this.paint(); + } + + select(pubKey: string): void { + this.selectedPubKey = pubKey; + this.paint(); + } + + clear(): void { + this.selectedPubKey = null; + this.panel.classList.add("hidden"); + this.hint.classList.remove("hidden"); + this.panel.textContent = ""; + } + + private paint(): void { + if (!this.selectedPubKey) return; + const pubKey = this.selectedPubKey; + + // Council memberships — derived from the topology each snapshot. + const councils = this.topology.filter((c) => + c.providers.some((p) => p.publicKey === pubKey) + ); + if (councils.length === 0) { + // PP no longer in any council (just removed) — clear panel. + this.clear(); + return; + } + + const label = councils + .flatMap((c) => c.providers) + .find((p) => p.publicKey === pubKey)?.label?.trim() ?? ""; + + const bundleCount = this.recent.reduce((n, ev) => { + if (ev.kind !== "channel_bundle") return n; + const p = ev.payload; + return typeof p.providerPublicKey === "string" && + p.providerPublicKey === pubKey + ? n + 1 + : n; + }, 0); + + this.panel.textContent = ""; + this.panel.classList.remove("hidden"); + this.hint.classList.add("hidden"); + + const header = document.createElement("header"); + header.className = "provider-details-header"; + const title = document.createElement("h2"); + title.className = "provider-details-title"; + title.textContent = label || truncateAddress(pubKey); + header.appendChild(title); + this.panel.appendChild(header); + + const grid = document.createElement("div"); + grid.className = "provider-details-grid"; + grid.appendChild(kvBlock("Councils", String(councils.length))); + grid.appendChild(kvBlock("Bundles (recent)", String(bundleCount))); + this.panel.appendChild(grid); + + const pkHeader = document.createElement("div"); + pkHeader.className = "provider-details-subheader"; + pkHeader.textContent = "Public key"; + this.panel.appendChild(pkHeader); + + const pk = document.createElement("div"); + pk.className = "provider-details-pubkey"; + pk.textContent = pubKey; + this.panel.appendChild(pk); + + const cHeader = document.createElement("div"); + cHeader.className = "provider-details-subheader"; + cHeader.textContent = "Member of"; + this.panel.appendChild(cHeader); + + const list = document.createElement("ul"); + list.className = "provider-details-council-list"; + for (const c of councils) { + const li = document.createElement("li"); + const name = document.createElement("span"); + name.className = "provider-details-council-name"; + name.textContent = c.name?.trim() || truncateAddress(c.id); + const juris = document.createElement("span"); + juris.className = "provider-details-council-juris"; + juris.textContent = c.jurisdictions.length > 0 + ? c.jurisdictions.map((j) => j.toUpperCase()).join(", ") + : "—"; + li.append(name, juris); + list.appendChild(li); + } + this.panel.appendChild(list); + } +} + +function kvBlock(label: string, value: string): HTMLElement { + const block = document.createElement("div"); + block.className = "provider-details-kv"; + const l = document.createElement("span"); + l.className = "provider-details-kv-label"; + l.textContent = label; + const v = document.createElement("span"); + v.className = "provider-details-kv-value"; + v.textContent = value; + block.append(l, v); + return block; +} diff --git a/src/views/topology.ts b/src/views/topology.ts deleted file mode 100644 index 7449669..0000000 --- a/src/views/topology.ts +++ /dev/null @@ -1,348 +0,0 @@ -import type { - CouncilTopologyEntry, - NetworkEvent, - NetworkEventKind, -} from "../lib/network-events.ts"; - -/** - * §2 (left) — Topology. Yellow MOONLIGHT center, council ring, PP-pubkey - * satellites, edges from MOONLIGHT to each council (thickness = recent - * throughput), red pulses on edges for every observed event. - * - * SVG-based; no physics. Positions are recomputed only when the topology - * frame changes (snapshot or hourly re-sync). - */ - -const SVG_NS = "http://www.w3.org/2000/svg"; - -const VIEW_W = 880; -const VIEW_H = 620; -const CX = VIEW_W / 2; -const CY = VIEW_H / 2; - -const MOON_RX = 70; -const MOON_RY = 36; -const COUNCIL_BASE_R = 24; -const PP_R = 5; -const COUNCIL_ORBIT = 210; -const PP_ORBIT_OFFSET = 28; -const PULSE_FADE_MS = 1_000; - -const KIND_PULSE_COLOR: Record = { - council_formed: "#9775fa", - provider_added: "#2f9e44", - provider_removed: "#868e96", - asset_registered: "#f59f00", - channel_deposit: "#e8590c", - channel_settlement: "#1c7ed6", - channel_bundle: "#15aabf", -}; - -const DEFAULT_PULSE_COLOR = "#f03e3e"; - -type CouncilLayout = { - x: number; - y: number; - r: number; - throughput: number; -}; - -export type CouncilClickHandler = (councilId: string) => void; - -export class Topology { - private root: HTMLElement; - private svg: SVGSVGElement; - private edgesLayer: SVGGElement; - private nodesLayer: SVGGElement; - private pulsesLayer: SVGGElement; - private councilLayouts = new Map(); - /** Recent-event count per council, decays linearly via a periodic timer. */ - private recentCounts = new Map(); - private decayTimer: number | null = null; - private onCouncilClick: CouncilClickHandler | null = null; - private selectedCouncilId: string | null = null; - - constructor() { - this.root = document.createElement("section"); - this.root.className = "section topology"; - - this.svg = document.createElementNS(SVG_NS, "svg"); - this.svg.setAttribute("viewBox", `0 0 ${VIEW_W} ${VIEW_H}`); - this.svg.setAttribute("preserveAspectRatio", "xMidYMid meet"); - this.svg.classList.add("topology-svg"); - - this.edgesLayer = this.makeGroup("edges"); - this.nodesLayer = this.makeGroup("nodes"); - this.pulsesLayer = this.makeGroup("pulses"); - this.svg.append(this.edgesLayer, this.nodesLayer, this.pulsesLayer); - this.root.appendChild(this.svg); - - this.decayTimer = globalThis.setInterval( - () => this.decayThroughput(), - 5_000, - ); - } - - element(): HTMLElement { - return this.root; - } - - setCouncilClickHandler(handler: CouncilClickHandler): void { - this.onCouncilClick = handler; - } - - /** - * Redraw the static layers (edges + council/PP nodes) from a topology - * frame. Pulses remain — they are short-lived overlays. - */ - render(topology: CouncilTopologyEntry[]): void { - this.edgesLayer.textContent = ""; - this.nodesLayer.textContent = ""; - this.councilLayouts.clear(); - this.drawCenter(); - if (topology.length === 0) { - this.drawEmptyState(); - return; - } - const angleStep = (Math.PI * 2) / topology.length; - for (let i = 0; i < topology.length; i++) { - const council = topology[i]; - const angle = -Math.PI / 2 + i * angleStep; - const cx = CX + Math.cos(angle) * COUNCIL_ORBIT; - const cy = CY + Math.sin(angle) * COUNCIL_ORBIT; - const r = Math.min( - COUNCIL_BASE_R + council.providers.length * 1.5, - 46, - ); - const layout: CouncilLayout = { x: cx, y: cy, r, throughput: 0 }; - this.councilLayouts.set(council.id, layout); - - const throughput = this.recentCounts.get(council.id) ?? 0; - layout.throughput = throughput; - this.drawEdge(council.id, throughput); - this.drawCouncilNode(council, layout); - } - } - - /** - * Animate a tx pulse from MOONLIGHT to the council associated with the - * event. Also nudges the council's "recent activity" counter so the - * edge thickness grows for a few seconds before decaying. - */ - pulse(event: NetworkEvent): void { - const layout = this.councilLayouts.get(event.councilId); - if (!layout) return; - this.recentCounts.set( - event.councilId, - (this.recentCounts.get(event.councilId) ?? 0) + 1, - ); - this.refreshEdge(event.councilId); - const color = KIND_PULSE_COLOR[event.kind] ?? DEFAULT_PULSE_COLOR; - const pulse = document.createElementNS(SVG_NS, "circle"); - pulse.setAttribute("cx", String(CX)); - pulse.setAttribute("cy", String(CY)); - pulse.setAttribute("r", "8"); - pulse.setAttribute("fill", color); - pulse.setAttribute("opacity", "0.95"); - pulse.classList.add("topology-pulse"); - this.pulsesLayer.appendChild(pulse); - - const animate = pulse.animate( - [ - { cx: `${CX}`, cy: `${CY}`, opacity: 0.95, r: 8 }, - { - cx: `${layout.x}`, - cy: `${layout.y}`, - opacity: 0, - r: 14, - }, - ], - { duration: PULSE_FADE_MS, easing: "ease-out", fill: "forwards" }, - ); - animate.onfinish = () => pulse.remove(); - } - - setSelectedCouncil(councilId: string | null): void { - this.selectedCouncilId = councilId; - for (const [id, _layout] of this.councilLayouts) { - const node = this.nodesLayer.querySelector( - `[data-council-id="${escapeAttrSelector(id)}"]`, - ); - if (node) { - node.classList.toggle("selected", id === councilId); - } - } - } - - destroy(): void { - if (this.decayTimer !== null) { - clearInterval(this.decayTimer); - this.decayTimer = null; - } - } - - // ── internals ────────────────────────────────────────────────────── - - private makeGroup(className: string): SVGGElement { - const g = document.createElementNS(SVG_NS, "g"); - g.setAttribute("class", className); - return g; - } - - private drawCenter(): void { - const ellipse = document.createElementNS(SVG_NS, "ellipse"); - ellipse.setAttribute("cx", String(CX)); - ellipse.setAttribute("cy", String(CY)); - ellipse.setAttribute("rx", String(MOON_RX)); - ellipse.setAttribute("ry", String(MOON_RY)); - ellipse.setAttribute("fill", "#fff3bf"); - ellipse.setAttribute("stroke", "#000"); - ellipse.setAttribute("stroke-width", "3"); - ellipse.classList.add("topology-center"); - this.nodesLayer.appendChild(ellipse); - - const label = document.createElementNS(SVG_NS, "text"); - label.setAttribute("x", String(CX)); - label.setAttribute("y", String(CY + 5)); - label.setAttribute("text-anchor", "middle"); - label.classList.add("topology-label-center"); - label.textContent = "MOONLIGHT"; - this.nodesLayer.appendChild(label); - } - - private drawEmptyState(): void { - const hint = document.createElementNS(SVG_NS, "text"); - hint.setAttribute("x", String(CX)); - hint.setAttribute("y", String(CY + 80)); - hint.setAttribute("text-anchor", "middle"); - hint.classList.add("topology-hint"); - hint.textContent = - "Waiting for councils to register with network-dashboard-platform…"; - this.nodesLayer.appendChild(hint); - } - - private drawEdge(councilId: string, throughput: number): void { - const layout = this.councilLayouts.get(councilId); - if (!layout) return; - const line = document.createElementNS(SVG_NS, "line"); - line.setAttribute("x1", String(CX)); - line.setAttribute("y1", String(CY)); - line.setAttribute("x2", String(layout.x)); - line.setAttribute("y2", String(layout.y)); - line.setAttribute("stroke", "#adb5bd"); - line.setAttribute("stroke-width", String(edgeWidth(throughput))); - line.setAttribute("stroke-linecap", "round"); - line.dataset.councilEdge = councilId; - this.edgesLayer.appendChild(line); - } - - private refreshEdge(councilId: string): void { - const line = this.edgesLayer.querySelector( - `line[data-council-edge="${escapeAttrSelector(councilId)}"]`, - ) as SVGLineElement | null; - if (!line) return; - const throughput = this.recentCounts.get(councilId) ?? 0; - line.setAttribute("stroke-width", String(edgeWidth(throughput))); - } - - private drawCouncilNode( - council: CouncilTopologyEntry, - layout: CouncilLayout, - ): void { - const group = document.createElementNS(SVG_NS, "g"); - group.dataset.councilId = council.id; - group.classList.add("topology-council"); - if (council.id === this.selectedCouncilId) group.classList.add("selected"); - group.style.cursor = "pointer"; - group.addEventListener("click", () => this.onCouncilClick?.(council.id)); - - const circle = document.createElementNS(SVG_NS, "circle"); - circle.setAttribute("cx", String(layout.x)); - circle.setAttribute("cy", String(layout.y)); - circle.setAttribute("r", String(layout.r)); - circle.setAttribute("fill", "#d3f9d8"); - circle.setAttribute("stroke", "#2f9e44"); - circle.setAttribute("stroke-width", "2"); - group.appendChild(circle); - - const name = document.createElementNS(SVG_NS, "text"); - name.setAttribute("x", String(layout.x)); - name.setAttribute("y", String(layout.y - 4)); - name.setAttribute("text-anchor", "middle"); - name.classList.add("topology-label-council"); - name.textContent = council.name ?? truncate(council.id, 10); - group.appendChild(name); - - if (council.jurisdictions.length > 0) { - const j = document.createElementNS(SVG_NS, "text"); - j.setAttribute("x", String(layout.x)); - j.setAttribute("y", String(layout.y + 10)); - j.setAttribute("text-anchor", "middle"); - j.classList.add("topology-label-jurisdiction"); - j.textContent = council.jurisdictions.join(" · "); - group.appendChild(j); - } - - // PP satellite dots: arranged on a small arc just outside the - // council circle, pointing radially away from MOONLIGHT. - const ppCount = council.providers.length; - if (ppCount > 0) { - const baseAngle = Math.atan2(layout.y - CY, layout.x - CX); - const arcSpread = Math.min(Math.PI * 0.6, 0.25 + ppCount * 0.15); - const startAngle = baseAngle - arcSpread / 2; - const angleStep = ppCount === 1 ? 0 : arcSpread / (ppCount - 1); - for (let i = 0; i < ppCount; i++) { - const a = startAngle + angleStep * i; - const px = layout.x + Math.cos(a) * (layout.r + PP_ORBIT_OFFSET); - const py = layout.y + Math.sin(a) * (layout.r + PP_ORBIT_OFFSET); - const dot = document.createElementNS(SVG_NS, "circle"); - dot.setAttribute("cx", String(px)); - dot.setAttribute("cy", String(py)); - dot.setAttribute("r", String(PP_R)); - dot.setAttribute("fill", "#ffe8cc"); - dot.setAttribute("stroke", "#e8590c"); - dot.setAttribute("stroke-width", "1.5"); - group.appendChild(dot); - } - } - - this.nodesLayer.appendChild(group); - } - - /** - * Each tick (every 5s) we subtract 1 from each council's recent count - * so the edge thickness fades back when activity dies down. - */ - private decayThroughput(): void { - let changed = false; - for (const [id, count] of this.recentCounts) { - const next = Math.max(0, count - 1); - if (next !== count) { - this.recentCounts.set(id, next); - changed = true; - } - } - if (!changed) return; - for (const id of this.councilLayouts.keys()) this.refreshEdge(id); - } -} - -function edgeWidth(throughput: number): number { - // Linear ramp clamped between idle (1.2) and saturated (6). - const w = 1.2 + throughput * 0.6; - return Math.min(6, w); -} - -function truncate(value: string, n: number): string { - if (value.length <= n) return value; - return `${value.slice(0, n - 1)}…`; -} - -/** - * CSS attribute selectors don't accept arbitrary strings; the contract - * IDs we receive are alphanumeric, but escape just-in-case to keep - * future inputs safe. - */ -function escapeAttrSelector(value: string): string { - return value.replace(/(["\\])/g, "\\$1"); -} diff --git a/src/views/world-map.ts b/src/views/world-map.ts index 23beb50..30fe8b3 100644 --- a/src/views/world-map.ts +++ b/src/views/world-map.ts @@ -25,6 +25,9 @@ import type { CouncilTopologyEntry } from "../lib/network-events.ts"; const HOVERED_SLOT = "hovered"; const REACHABLE_SLOT = "reachable"; const DIMMED_SLOT = "dimmed"; +const SELECTED_COUNTRY_SLOT = "active-country"; + +export type CountryClickHandler = (countryCode: string) => void; export class WorldMap { private root: HTMLElement; @@ -36,6 +39,8 @@ export class WorldMap { private councils: CouncilTopologyEntry[] = []; private allCodes: string[] = []; private failed = false; + private onCountryClick: CountryClickHandler | null = null; + private selectedCountry: string | null = null; constructor() { this.root = document.createElement("section"); @@ -68,6 +73,28 @@ export class WorldMap { return this.root; } + /** + * Register a click handler. The map fires this on every country click; the + * consumer decides whether to toggle a side panel, navigate, etc. Pass + * null to clear. + */ + setOnCountryClick(handler: CountryClickHandler | null): void { + this.onCountryClick = handler; + } + + /** + * Highlight a country as the persistent "active" selection. Independent + * of hover. Pass null to clear. + */ + setSelectedCountry(code: string | null): void { + if (!this.handle) { + this.selectedCountry = code; + return; + } + this.selectedCountry = code; + this.handle.setSlot(SELECTED_COUNTRY_SLOT, code ? [code] : []); + } + render(topology: CouncilTopologyEntry[]): void { this.councils = topology; this.allCodes = Array.from( @@ -95,11 +122,15 @@ export class WorldMap { selected: this.allCodes, svgUrl: "/world-map.svg", onHover: (code) => this.handleHover(code), + onSelect: (code) => this.onCountryClick?.(code), }) .then((handle) => { this.handle = handle; this.mapHost.insertBefore(handle.element, this.popover); handle.setSelected(this.allCodes); + if (this.selectedCountry) { + handle.setSlot(SELECTED_COUNTRY_SLOT, [this.selectedCountry]); + } this.updateStatus(); this.mapHost.addEventListener( "mousemove", From 83e6c935f4d8b81bdf670408ca0a29f374bfce25 Mon Sep 17 00:00:00 2001 From: Gorka Date: Thu, 21 May 2026 09:49:40 -0300 Subject: [PATCH 7/8] feat: repaint counter strip on every live event LiveFrame mirror gains the new `counters` field. ws-client passes it through to the onEvent handler. app.ts calls `counters.render(live)` instead of the old `bumpFromLiveEvent()` hack that only ticked eventsLast24h. All 6 counter tiles (COUNCILS / ACTIVE PPs / EVENTS-24h / ASSETS / THROUGHPUT / LATENCY) now update in real time without waiting for a fresh snapshot. --- src/app.ts | 4 ++-- src/lib/network-events.ts | 6 ++++++ src/lib/ws-client.ts | 6 ++++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/app.ts b/src/app.ts index b85a210..f9b6ceb 100644 --- a/src/app.ts +++ b/src/app.ts @@ -139,9 +139,9 @@ function bootstrap() { providerDetails.setTopology(frame.topology); providerDetails.setRecent(frame.recent); }, - onEvent: (event) => { + onEvent: (event, liveCounters) => { feed.append(event); - counters.bumpFromLiveEvent(); + counters.render(liveCounters); }, }); } diff --git a/src/lib/network-events.ts b/src/lib/network-events.ts index 5035071..1cc7a7b 100644 --- a/src/lib/network-events.ts +++ b/src/lib/network-events.ts @@ -83,6 +83,12 @@ export type SnapshotFrame = { export type LiveFrame = { type: "event"; event: NetworkEvent; + /** + * Snapshot of the counters at the moment this event was broadcast. + * Lets the SPA refresh the counter strip on every event instead of + * waiting for the next snapshot. + */ + counters: Counters; }; export type ServerFrame = SnapshotFrame | LiveFrame; diff --git a/src/lib/ws-client.ts b/src/lib/ws-client.ts index 25b97c1..c1919aa 100644 --- a/src/lib/ws-client.ts +++ b/src/lib/ws-client.ts @@ -1,4 +1,5 @@ import { + type Counters, NETWORK_WS_SUBPROTOCOL, type NetworkEvent, parseServerFrame, @@ -21,7 +22,8 @@ export type WsStatus = "idle" | "connecting" | "open" | "closed"; export type WsHandlers = { onSnapshot: (frame: SnapshotFrame) => void; - onEvent: (event: NetworkEvent) => void; + /** Live event + the counters snapshot the backend emitted alongside it. */ + onEvent: (event: NetworkEvent, counters: Counters) => void; onStatusChange: (status: WsStatus) => void; }; @@ -102,7 +104,7 @@ export function connectNetworkPlatform( if (frame.type === "snapshot") { handlers.onSnapshot(frame); } else { - handlers.onEvent(frame.event); + handlers.onEvent(frame.event, frame.counters); } }; socket.onclose = () => { From f8c0dd765303cb4832ce9a97f37e2e46d6ce7365 Mon Sep 17 00:00:00 2001 From: Gorka Date: Thu, 21 May 2026 10:05:21 -0300 Subject: [PATCH 8/8] chore: release v0.2.12 --- deno.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deno.json b/deno.json index fcf1a4c..1dfaa29 100644 --- a/deno.json +++ b/deno.json @@ -1,5 +1,5 @@ { - "version": "0.2.11", + "version": "0.2.12", "license": "MIT", "tasks": { "dev": "deno run --allow-all --watch src/server.ts",