diff --git a/deno.json b/deno.json index b27481a..1dfaa29 100644 --- a/deno.json +++ b/deno.json @@ -1,12 +1,12 @@ { - "version": "0.2.10", + "version": "0.2.12", "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/" @@ -18,7 +18,8 @@ "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" + "@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 696d64f..da79b4e 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,342 +58,22 @@ ] } }, - "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", "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/nav/mod.ts": "4bce92a8e347e12953f2fc7e4329e0b58e3dcb8d1c872054e2d30beeba4373e0" + "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.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": [ "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..e922311 100644 --- a/src/app-styles.css +++ b/src/app-styles.css @@ -1,179 +1,917 @@ /* * 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.) + * 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. */ -/* Nav-brand hover (lib's nav.css doesn't ship this variant) */ -.nav-brand:hover { - opacity: 0.9; +:root { + /* 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; } -/* Tables */ -table { - width: 100%; - border-collapse: collapse; - margin: 1rem 0; +/* Override @moonlight/ui's .container width — the dashboard sprawls. */ +main.container { + max-width: 1440px; + margin: 1.25rem auto 2rem; + padding: 0 1.5rem; } -th, td { - padding: 0.75rem 1rem; - text-align: left; - border-bottom: 1px solid var(--border); +.dashboard { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.row { + display: grid; + gap: 1rem; +} + +.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 { + grid-template-columns: minmax(0, 1fr) minmax(280px, 420px); +} + +.section { + background: var(--surface); + border: 1px solid var(--border); + border-radius: 10px; + padding: 0.875rem 1rem; + box-sizing: border-box; +} + +/* §1 — Counter strip ──────────────────────────────────────────────── */ + +.counter-strip { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 0.75rem; + padding: 0.75rem; +} + +.counter-tile { + 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: 0.25rem; +} + +.counter-label { + font-size: 0.65rem; + font-weight: 600; + letter-spacing: 0.06em; + color: var(--text-muted); + text-transform: uppercase; +} + +.counter-value { + font-size: 1.5rem; + font-weight: 700; + color: var(--text); + font-variant-numeric: tabular-nums; +} + +/* §3 — Activity feed ──────────────────────────────────────────────── */ + +.activity-feed, +.council-details, +.provider-details { + display: flex; + flex-direction: column; + min-height: 420px; + padding: 0.75rem; +} + +.activity-feed { + padding: 0.75rem; +} + +.activity-feed-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 0.75rem; +} + +.activity-feed-title { + font-weight: 700; + font-size: 0.75rem; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--text-muted); +} + +.activity-feed-status { + font-size: 0.7rem; + padding: 0.125rem 0.5rem; + border-radius: 999px; + background: rgba(139, 143, 163, 0.18); + color: var(--text-muted); +} + +.activity-feed-status[data-state="open"] { + background: rgba(74, 222, 128, 0.18); + color: var(--active); +} + +.activity-feed-status[data-state="connecting"] { + background: rgba(245, 158, 11, 0.18); + color: var(--pending); +} + +.activity-feed-status[data-state="closed"] { + background: rgba(239, 68, 68, 0.2); + color: var(--inactive); +} + +.activity-feed-list { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 0.5rem; + overflow-y: auto; +} + +.activity-card { + display: flex; + gap: 0.625rem; + align-items: flex-start; + 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; +} + +.activity-card.fading { + opacity: 0; + transform: translateY(-4px); +} + +@keyframes card-in { + from { + transform: translateY(-6px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +.activity-card.kind-council_formed { + border-left-color: var(--kind-council); +} +.activity-card.kind-provider_added { + border-left-color: var(--kind-pp-add); +} +.activity-card.kind-provider_removed { + border-left-color: var(--kind-pp-remove); +} +.activity-card.kind-asset_registered { + border-left-color: var(--kind-asset); +} +.activity-card.kind-channel_settlement { + border-left-color: var(--kind-settlement); +} +.activity-card.kind-channel_deposit { + border-left-color: var(--kind-deposit); +} +.activity-card.kind-channel_bundle { + border-left-color: var(--kind-bundle); +} + +.activity-glyph { + font-size: 1.125rem; + line-height: 1.1; + font-weight: 700; + 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: 0.125rem; + min-width: 0; } -th { +.activity-title { + font-size: 0.75rem; + font-weight: 700; + color: var(--text); +} + +.activity-council { + font-size: 0.75rem; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.activity-detail { + font-size: 0.7rem; color: var(--text-muted); + font-family: var(--font-mono); +} + +/* §3 — Council details ────────────────────────────────────────────── */ + +.council-details { + padding: 0.875rem 1.125rem; +} + +.council-details-hint { + font-size: 0.8125rem; + color: var(--text-muted); + font-style: italic; +} + +.council-details-hint.hidden, +.council-details-panel.hidden { + display: none; +} + +.council-details-header { + display: flex; + gap: 1rem; + align-items: baseline; + margin-bottom: 0.625rem; +} + +.council-details-name { + margin: 0; + font-size: 1.125rem; + font-weight: 700; + color: var(--text); +} + +.council-details-jurisdiction { 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: 0.625rem; + margin-bottom: 0.75rem; +} + +.kv-block { + display: flex; + flex-direction: column; + 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: 0.65rem; + font-weight: 600; text-transform: uppercase; - letter-spacing: 0.05em; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +.kv-value { + font-size: 1.0625rem; + font-weight: 700; + color: var(--text); + font-variant-numeric: tabular-nums; } -/* Utility */ -.text-muted { +.kv-sub { + font-size: 0.65rem; color: var(--text-muted); } -/* Loading + clickable row */ -.loading { +.council-details-subheader { + margin: 0.75rem 0 0.375rem; + font-size: 0.75rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.06em; color: var(--text-muted); - padding: 2rem 0; } -.clickable-row { +.council-details-pp-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.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; - transition: background 0.15s; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.clickable-row:hover { - background: var(--surface); +.council-details-pp-button:hover { + background: rgba(255, 255, 255, 0.06); + border-color: var(--border); } -/* Map */ -.map-container { - background: var(--surface); +/* §4 — Sparklines ─────────────────────────────────────────────────── */ + +.sparklines { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 0.75rem; +} + +.sparkline-card { + display: flex; + flex-direction: column; + gap: 0.375rem; + padding: 0.5rem 0.625rem; border: 1px solid var(--border); border-radius: 8px; - padding: 1rem; - margin: 1rem 0 2rem; + background: rgba(255, 255, 255, 0.03); +} + +.sparkline-header { + display: flex; + justify-content: space-between; + align-items: baseline; +} + +.sparkline-title { + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +.sparkline-value { + font-size: 1rem; + font-weight: 700; + color: var(--text); + font-variant-numeric: tabular-nums; +} + +.sparkline-svg { + width: 100%; + height: 80px; +} + +.sparkline-unit { + font-size: 0.625rem; + color: var(--text-muted); + align-self: flex-end; +} + +/* §4 — Asset breakdown ────────────────────────────────────────────── */ + +.asset-breakdown { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.asset-breakdown-header { + margin-bottom: 0.25rem; +} + +.asset-breakdown-title { + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +.asset-breakdown-list { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.asset-breakdown-empty { + 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: 0.5rem; + align-items: center; + font-size: 0.75rem; +} + +.asset-code { + font-weight: 700; + color: var(--text); +} + +.asset-bar-box { + background: rgba(255, 255, 255, 0.05); + border-radius: 4px; + height: 12px; overflow: hidden; } +.asset-bar { + height: 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: var(--text-muted); + text-align: right; +} + +.asset-amount { + font-variant-numeric: tabular-nums; + text-align: right; + font-family: var(--font-mono); + color: var(--text); +} + +/* §5 — World map ──────────────────────────────────────────────────── */ + .world-map { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.world-map-header { + display: flex; + justify-content: space-between; + align-items: baseline; +} + +.world-map-title { + font-size: 0.65rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); +} + +.world-map-status { + font-size: 0.7rem; + color: var(--text-muted); +} + +/* 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: auto; + height: 320px; + position: relative; } -.council-dot { +/* 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-svg .world-map-country.reachable, +.world-map-svg .world-map-country.reachable path { + fill: #86efac; +} + +/* 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); +} + +/* 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); - opacity: 0.8; - cursor: pointer; - transition: opacity 0.15s, r 0.15s; + stroke: var(--primary-hover); + stroke-width: 1; } -.council-dot:hover { - opacity: 1; - fill: var(--primary-hover); +/* §5 — Country details side panel ────────────────────────────────── */ + +.country-details { + padding: 0.875rem 1.125rem; + display: flex; + flex-direction: column; } -.council-label { - fill: var(--text-muted); - font-size: 10px; - text-anchor: middle; - pointer-events: none; +.country-details-hint { + font-size: 0.8125rem; + color: var(--text-muted); + font-style: italic; } -/* Council grid */ -.council-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); - gap: 1rem; - margin-top: 1rem; +.country-details-hint.hidden, +.country-details-panel.hidden { + display: none; } -.council-card { - background: var(--surface); +.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: 8px; - padding: 1.25rem; - text-decoration: none; + border-radius: 6px; + padding: 0.5rem 0.625rem; + display: flex; + flex-direction: column; + gap: 0.125rem; + text-align: left; color: var(--text); - transition: border-color 0.15s; + cursor: pointer; } -.council-card:hover { +.country-details-council:hover { + background: rgba(255, 255, 255, 0.07); border-color: var(--primary); } -.council-card-header { +.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; - justify-content: space-between; - align-items: center; - margin-bottom: 0.5rem; + 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); } -.council-name { +.provider-details-kv-label { + font-size: 0.625rem; font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: var(--text-muted); } -.council-card-meta { - font-size: 0.875rem; - color: var(--text-muted); - margin-bottom: 0.25rem; +.provider-details-kv-value { + font-size: 1.0625rem; + font-weight: 700; + color: var(--text); + font-variant-numeric: tabular-nums; } -.council-card-id { - font-size: 0.7rem; +.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); } -/* Transaction feed */ -.feed-list { +.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; - margin-top: 1rem; + font-size: 0.75rem; + overflow: hidden; } -.feed-item { +.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; + pointer-events: none; + z-index: 4; + min-width: 180px; + max-width: 280px; background: var(--surface); border: 1px solid var(--border); - border-radius: 8px; - padding: 0.75rem 1rem; + 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); } -.feed-item-header { +.world-map-popover-list { + list-style: none; + margin: 0; + padding: 0; display: flex; - align-items: center; - gap: 0.75rem; - margin-bottom: 0.25rem; + flex-direction: column; + gap: 0.25rem; } -.feed-event-type { - font-weight: 500; - font-size: 0.875rem; +.world-map-popover-list li { + display: flex; + justify-content: space-between; + gap: 0.5rem; } -.feed-item-details { - font-size: 0.8rem; - margin-bottom: 0.25rem; +.world-map-popover-name { + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } -.feed-item-id { - font-size: 0.7rem; +.world-map-popover-meta { color: var(--text-muted); + font-variant-numeric: tabular-nums; } -/* 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; +.world-map-popover-empty { + color: var(--text-muted); + font-style: italic; +} + +@media (max-width: 960px) { + .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; + } + .counter-strip { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + .sparklines { + grid-template-columns: 1fr; + } + .activity-feed, + .council-details, + .provider-details { + min-height: 320px; + } } diff --git a/src/app.ts b/src/app.ts index e00f669..f9b6ceb 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,34 +1,149 @@ -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. + * + * 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 + * + * 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 { 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; + 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"); + app.textContent = ""; + + const dashboard = document.createElement("div"); + dashboard.className = "dashboard"; + + const counters = new CounterStrip(); + + // 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"; + const sparklines = new SparklineGroup(); + const assets = new AssetBreakdown(); + trendsRow.append(sparklines.element(), assets.element()); + + dashboard.append( + counters.element(), + grid, + trendsRow, + ); + + const nav = renderNav({ + brand: "Network Dashboard", + version: __APP_VERSION__, + }); + app.appendChild(pageLayout(nav, dashboard)); + return { + counters, + feed, + details, + sparklines, + assets, + worldMap, + countryDetails, + providerDetails, + }; +} + +function bootstrap() { + const { + counters, + feed, + details, + sparklines, + assets, + worldMap, + countryDetails, + providerDetails, + } = renderShell(); + + // Drilldown wiring: country → council → PP. + worldMap.setOnCountryClick((countryCode) => { + countryDetails.select(countryCode); + worldMap.setSelectedCountry(countryCode); + }); + countryDetails.setOnCouncilClick((councilId) => { + details.select(councilId); + providerDetails.clear(); + }); + details.setOnPpClick((pubKey) => { + providerDetails.select(pubKey); + }); + + 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); + 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, liveCounters) => { + feed.append(event); + counters.render(liveCounters); + }, }); } + +bootstrap(); diff --git a/src/build.ts b/src/build.ts index 0b0183d..4c2f2f7 100644 --- a/src/build.ts +++ b/src/build.ts @@ -1,29 +1,34 @@ /** - * 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"; +async function writeHealthJson(version: string): Promise { + const health = { status: "ok", service: "network-dashboard", version }; + await Deno.writeTextFile("public/health.json", JSON.stringify(health) + "\n"); + 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. -// Do not change without bumping the consumer-side deps explicitly. -const UI_LIB_TAG = "v0.3.1"; +// Same pattern + tag as provider-console / council-console. +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 writeHealthJson(version: string): Promise { - const health = { status: "ok", service: "network-dashboard", version }; - await Deno.writeTextFile("public/health.json", JSON.stringify(health) + "\n"); - console.log(`Built public/health.json (network-dashboard ${version})`); -} - async function buildStyles(): Promise { const parts: string[] = []; for (const path of UI_LIB_CSS_FILES) { @@ -35,8 +40,9 @@ async function buildStyles(): Promise { `Failed to fetch ${url}: ${res.status} ${res.statusText}`, ); } - const body = await res.text(); - parts.push(`/* @moonlight/ui ${UI_LIB_TAG} — ${path} */\n${body}`); + parts.push( + `/* @moonlight/ui ${UI_LIB_TAG} — ${path} */\n${await res.text()}`, + ); } const appStyles = await Deno.readTextFile("src/app-styles.css"); parts.push(`/* network-dashboard app-styles */\n${appStyles}`); @@ -51,25 +57,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 +70,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/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/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..1cc7a7b --- /dev/null +++ b/src/lib/network-events.ts @@ -0,0 +1,124 @@ +/** + * 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; + /** + * 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; + +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/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/lib/ws-client.ts b/src/lib/ws-client.ts new file mode 100644 index 0000000..c1919aa --- /dev/null +++ b/src/lib/ws-client.ts @@ -0,0 +1,147 @@ +import { + type Counters, + 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; + /** Live event + the counters snapshot the backend emitted alongside it. */ + onEvent: (event: NetworkEvent, counters: Counters) => 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, frame.counters); + } + }; + 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..4cebf1e --- /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 = 5 * 60 * 1000; +const MAX_VISIBLE = 10; + +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..80bd67b --- /dev/null +++ b/src/views/council-details.ts @@ -0,0 +1,235 @@ +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 type PpClickHandler = (publicKey: string) => void; + +export class CouncilDetails { + private root: HTMLElement; + private hint: HTMLElement; + private panel: HTMLElement; + private councilsById = new Map(); + private rolling: Record = {}; + private selectedCouncilId: string | null = null; + private onPpClick: PpClickHandler | 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(); + } + + setOnPpClick(handler: PpClickHandler | null): void { + this.onPpClick = handler; + } + + 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"); + 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); + } + } + + /** + * 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/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/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/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/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/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..30fe8b3 --- /dev/null +++ b/src/views/world-map.ts @@ -0,0 +1,253 @@ +import { + COUNTRIES, + renderWorldMap, + type WorldMapHandle, +} from "@moonlight/ui/world-map"; +import type { CouncilTopologyEntry } from "../lib/network-events.ts"; + +/** + * §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 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; + private mapHost: HTMLDivElement; + private status: HTMLElement; + private popover: HTMLDivElement; + private handle: WorldMapHandle | null = null; + private pendingMount: Promise | null = null; + 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"); + 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-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); + } + + element(): HTMLElement { + 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( + 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.handle) { + this.handle.setSelected(this.allCodes); + this.updateStatus(); + return; + } + if (this.pendingMount) return; + + this.pendingMount = renderWorldMap({ + 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", + (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; + }); + } + + private updateStatus(): void { + this.status.textContent = this.allCodes.length === 0 + ? "no jurisdictions" + : `${this.allCodes.length} jurisdiction${ + this.allCodes.length === 1 ? "" : "s" + }`; + } + + 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; + } + + // 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; + } + + // 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)); + + this.handle.setSlot(HOVERED_SLOT, [code]); + this.handle.setSlot(REACHABLE_SLOT, Array.from(reachable)); + this.handle.setSlot(DIMMED_SLOT, dim); + this.renderPopoverCouncils(code, hoveredCouncils); + } + + 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.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`; + } +}