diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2f7d325..5718bf7 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -8,9 +8,7 @@ jobs: deploy: runs-on: ubuntu-latest env: - COUNCIL_PLATFORM_URL: "${{ secrets.COUNCIL_PLATFORM_URL }}" - STELLAR_NETWORK: "${{ secrets.STELLAR_NETWORK }}" - RPC_URL: "${{ secrets.RPC_URL }}" + NETWORK_DASHBOARD_PLATFORM_URL: "${{ secrets.NETWORK_DASHBOARD_PLATFORM_URL }}" steps: - uses: actions/checkout@v4 @@ -21,32 +19,21 @@ jobs: - name: Validate testnet config inputs run: | set -euo pipefail - for var in COUNCIL_PLATFORM_URL STELLAR_NETWORK RPC_URL; do - if [ -z "${!var}" ]; then - echo "::error::Required env var $var is empty" - exit 1 - fi - done - case "$STELLAR_NETWORK" in - testnet|mainnet) ;; - *) echo "::error::STELLAR_NETWORK must be 'testnet' or 'mainnet' (got: $STELLAR_NETWORK)"; exit 1 ;; + if [ -z "$NETWORK_DASHBOARD_PLATFORM_URL" ]; then + echo "::error::NETWORK_DASHBOARD_PLATFORM_URL is empty" + exit 1 + fi + case "$NETWORK_DASHBOARD_PLATFORM_URL" in + https://*) ;; + *) echo "::error::NETWORK_DASHBOARD_PLATFORM_URL must start with https:// (got: $NETWORK_DASHBOARD_PLATFORM_URL)"; exit 1 ;; esac - for var in COUNCIL_PLATFORM_URL RPC_URL; do - val="${!var}" - case "$val" in - https://*) ;; - *) echo "::error::$var must start with https:// (got: $val)"; exit 1 ;; - esac - done - name: Generate production config run: | deno eval " const config = { environment: 'production', - stellarNetwork: Deno.env.get('STELLAR_NETWORK'), - rpcUrl: Deno.env.get('RPC_URL'), - councilPlatformUrl: Deno.env.get('COUNCIL_PLATFORM_URL'), + networkDashboardPlatformUrl: Deno.env.get('NETWORK_DASHBOARD_PLATFORM_URL'), }; Deno.writeTextFileSync('public/config.js', 'window.__DASHBOARD_CONFIG__ = ' + JSON.stringify(config, null, 2) + ';\n'); @@ -72,9 +59,7 @@ jobs: runs-on: ubuntu-latest environment: mainnet env: - COUNCIL_PLATFORM_URL: "${{ secrets.MAINNET_COUNCIL_PLATFORM_URL }}" - STELLAR_NETWORK: "${{ secrets.MAINNET_STELLAR_NETWORK }}" - RPC_URL: "${{ secrets.MAINNET_RPC_URL }}" + NETWORK_DASHBOARD_PLATFORM_URL: "${{ secrets.MAINNET_NETWORK_DASHBOARD_PLATFORM_URL }}" steps: - uses: actions/checkout@v4 @@ -85,32 +70,21 @@ jobs: - name: Validate mainnet config inputs run: | set -euo pipefail - for var in COUNCIL_PLATFORM_URL STELLAR_NETWORK RPC_URL; do - if [ -z "${!var}" ]; then - echo "::error::Required env var $var is empty" - exit 1 - fi - done - case "$STELLAR_NETWORK" in - testnet|mainnet) ;; - *) echo "::error::STELLAR_NETWORK must be 'testnet' or 'mainnet' (got: $STELLAR_NETWORK)"; exit 1 ;; + if [ -z "$NETWORK_DASHBOARD_PLATFORM_URL" ]; then + echo "::error::MAINNET_NETWORK_DASHBOARD_PLATFORM_URL is empty" + exit 1 + fi + case "$NETWORK_DASHBOARD_PLATFORM_URL" in + https://*) ;; + *) echo "::error::MAINNET_NETWORK_DASHBOARD_PLATFORM_URL must start with https:// (got: $NETWORK_DASHBOARD_PLATFORM_URL)"; exit 1 ;; esac - for var in COUNCIL_PLATFORM_URL RPC_URL; do - val="${!var}" - case "$val" in - https://*) ;; - *) echo "::error::$var must start with https:// (got: $val)"; exit 1 ;; - esac - done - name: Generate mainnet config run: | deno eval " const config = { environment: 'production', - stellarNetwork: Deno.env.get('STELLAR_NETWORK'), - rpcUrl: Deno.env.get('RPC_URL'), - councilPlatformUrl: Deno.env.get('COUNCIL_PLATFORM_URL'), + networkDashboardPlatformUrl: Deno.env.get('NETWORK_DASHBOARD_PLATFORM_URL'), }; Deno.writeTextFileSync('public/config.js', 'window.__DASHBOARD_CONFIG__ = ' + JSON.stringify(config, null, 2) + ';\n'); diff --git a/deno.json b/deno.json index b27481a..a1aba03 100644 --- a/deno.json +++ b/deno.json @@ -1,12 +1,12 @@ { - "version": "0.2.10", + "version": "0.2.11", "license": "MIT", "tasks": { "dev": "deno run --allow-all --watch src/server.ts", "build": "deno run --allow-all src/build.ts", "serve": "deno run --allow-all src/server.ts", "test": "deno test --allow-all src/", - "check": "deno check src/server.ts src/build.ts src/app.ts src/lib/*.ts src/views/*.ts src/components/*.ts", + "check": "deno check src/server.ts src/build.ts src/app.ts src/lib/*.ts src/views/*.ts", "lint": "deno lint src/", "fmt": "deno fmt src/", "fmt:check": "deno fmt --check src/" @@ -17,8 +17,6 @@ }, "imports": { "@std/path": "jsr:@std/path@^1.0.0", - "@std/assert": "jsr:@std/assert@^1.0.0", - "stellar-sdk": "npm:@stellar/stellar-sdk@^15.0.1", - "@moonlight/ui/nav": "https://raw.githubusercontent.com/Moonlight-Protocol/ui/v0.3.1/src/nav/mod.ts" + "@std/assert": "jsr:@std/assert@^1.0.0" } } diff --git a/deno.lock b/deno.lock index 696d64f..6b24ca9 100644 --- a/deno.lock +++ b/deno.lock @@ -9,8 +9,7 @@ "jsr:@std/json@~0.213.1": "0.213.1", "jsr:@std/jsonc@0.213": "0.213.1", "jsr:@std/path@0.213": "0.213.1", - "jsr:@std/path@1": "1.1.4", - "npm:@stellar/stellar-sdk@^15.0.1": "15.0.1" + "jsr:@std/path@1": "1.1.4" }, "jsr": { "@luca/esbuild-deno-loader@0.10.3": { @@ -59,330 +58,6 @@ ] } }, - "npm": { - "@noble/curves@1.9.7": { - "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==", - "dependencies": [ - "@noble/hashes" - ] - }, - "@noble/hashes@1.8.0": { - "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==" - }, - "@stellar/js-xdr@4.0.0": { - "integrity": "sha512-+NmNa7Tk5BI5XFdy/6xGTqAN4J9a9KgCrCGhj2uEUTCBhLkch0M+QbKzNH8zEnejWe0p8w+0q5hUVX6L3OzoVA==" - }, - "@stellar/stellar-base@15.0.0": { - "integrity": "sha512-XQhxUr9BYiEcFcgc4oWcCMR9QJCny/GmmGsuwPKf/ieIcOeb5149KLHYx9mJCA0ea8QbucR2/GzV58QbXOTxQA==", - "dependencies": [ - "@noble/curves", - "@stellar/js-xdr", - "base32.js", - "bignumber.js", - "buffer", - "sha.js" - ] - }, - "@stellar/stellar-sdk@15.0.1": { - "integrity": "sha512-iZjWKXtfohsPh+CX9wRyQNIlXLeA9VyuQB6UMC7AFBD9XnR92eOjnlfeONzk/Bsrkk6+UPlpzSy2MuF+ydHP1A==", - "dependencies": [ - "@stellar/stellar-base", - "axios", - "bignumber.js", - "commander", - "eventsource", - "feaxios", - "randombytes", - "toml", - "urijs" - ], - "bin": true - }, - "asynckit@0.4.0": { - "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" - }, - "available-typed-arrays@1.0.7": { - "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", - "dependencies": [ - "possible-typed-array-names" - ] - }, - "axios@1.14.0": { - "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==", - "dependencies": [ - "follow-redirects", - "form-data", - "proxy-from-env" - ] - }, - "base32.js@0.1.0": { - "integrity": "sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ==" - }, - "base64-js@1.5.1": { - "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" - }, - "bignumber.js@9.3.1": { - "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==" - }, - "buffer@6.0.3": { - "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", - "dependencies": [ - "base64-js", - "ieee754" - ] - }, - "call-bind-apply-helpers@1.0.2": { - "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", - "dependencies": [ - "es-errors", - "function-bind" - ] - }, - "call-bind@1.0.9": { - "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", - "dependencies": [ - "call-bind-apply-helpers", - "es-define-property", - "get-intrinsic", - "set-function-length" - ] - }, - "call-bound@1.0.4": { - "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", - "dependencies": [ - "call-bind-apply-helpers", - "get-intrinsic" - ] - }, - "combined-stream@1.0.8": { - "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dependencies": [ - "delayed-stream" - ] - }, - "commander@14.0.3": { - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==" - }, - "define-data-property@1.1.4": { - "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", - "dependencies": [ - "es-define-property", - "es-errors", - "gopd" - ] - }, - "delayed-stream@1.0.0": { - "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==" - }, - "dunder-proto@1.0.1": { - "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", - "dependencies": [ - "call-bind-apply-helpers", - "es-errors", - "gopd" - ] - }, - "es-define-property@1.0.1": { - "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==" - }, - "es-errors@1.3.0": { - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==" - }, - "es-object-atoms@1.1.1": { - "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", - "dependencies": [ - "es-errors" - ] - }, - "es-set-tostringtag@2.1.0": { - "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dependencies": [ - "es-errors", - "get-intrinsic", - "has-tostringtag", - "hasown" - ] - }, - "eventsource@2.0.2": { - "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==" - }, - "feaxios@0.0.23": { - "integrity": "sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==", - "dependencies": [ - "is-retry-allowed" - ] - }, - "follow-redirects@1.16.0": { - "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==" - }, - "for-each@0.3.5": { - "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", - "dependencies": [ - "is-callable" - ] - }, - "form-data@4.0.5": { - "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", - "dependencies": [ - "asynckit", - "combined-stream", - "es-set-tostringtag", - "hasown", - "mime-types" - ] - }, - "function-bind@1.1.2": { - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==" - }, - "get-intrinsic@1.3.0": { - "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", - "dependencies": [ - "call-bind-apply-helpers", - "es-define-property", - "es-errors", - "es-object-atoms", - "function-bind", - "get-proto", - "gopd", - "has-symbols", - "hasown", - "math-intrinsics" - ] - }, - "get-proto@1.0.1": { - "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", - "dependencies": [ - "dunder-proto", - "es-object-atoms" - ] - }, - "gopd@1.2.0": { - "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==" - }, - "has-property-descriptors@1.0.2": { - "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", - "dependencies": [ - "es-define-property" - ] - }, - "has-symbols@1.1.0": { - "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==" - }, - "has-tostringtag@1.0.2": { - "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dependencies": [ - "has-symbols" - ] - }, - "hasown@2.0.3": { - "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", - "dependencies": [ - "function-bind" - ] - }, - "ieee754@1.2.1": { - "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" - }, - "inherits@2.0.4": { - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" - }, - "is-callable@1.2.7": { - "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==" - }, - "is-retry-allowed@3.0.0": { - "integrity": "sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A==" - }, - "is-typed-array@1.1.15": { - "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", - "dependencies": [ - "which-typed-array" - ] - }, - "isarray@2.0.5": { - "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==" - }, - "math-intrinsics@1.1.0": { - "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==" - }, - "mime-db@1.52.0": { - "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==" - }, - "mime-types@2.1.35": { - "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", - "dependencies": [ - "mime-db" - ] - }, - "possible-typed-array-names@1.1.0": { - "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==" - }, - "proxy-from-env@2.1.0": { - "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==" - }, - "randombytes@2.1.0": { - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dependencies": [ - "safe-buffer" - ] - }, - "safe-buffer@5.2.1": { - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" - }, - "set-function-length@1.2.2": { - "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", - "dependencies": [ - "define-data-property", - "es-errors", - "function-bind", - "get-intrinsic", - "gopd", - "has-property-descriptors" - ] - }, - "sha.js@2.4.12": { - "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", - "dependencies": [ - "inherits", - "safe-buffer", - "to-buffer" - ], - "bin": true - }, - "to-buffer@1.2.2": { - "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", - "dependencies": [ - "isarray", - "safe-buffer", - "typed-array-buffer" - ] - }, - "toml@3.0.0": { - "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==" - }, - "typed-array-buffer@1.0.3": { - "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", - "dependencies": [ - "call-bound", - "es-errors", - "is-typed-array" - ] - }, - "urijs@1.19.11": { - "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==" - }, - "which-typed-array@1.1.20": { - "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", - "dependencies": [ - "available-typed-arrays", - "call-bind", - "call-bound", - "for-each", - "get-proto", - "gopd", - "has-tostringtag" - ] - } - }, "remote": { "https://deno.land/x/denoflate@1.2.1/mod.ts": "f5628e44b80b3d80ed525afa2ba0f12408e3849db817d47a883b801f9ce69dd6", "https://deno.land/x/denoflate@1.2.1/pkg/denoflate.js": "b9f9ad9457d3f12f28b1fb35c555f57443427f74decb403113d67364e4f2caf4", @@ -393,8 +68,7 @@ "workspace": { "dependencies": [ "jsr:@std/assert@1", - "jsr:@std/path@1", - "npm:@stellar/stellar-sdk@^15.0.1" + "jsr:@std/path@1" ] } } diff --git a/public/config.js b/public/config.js index da784ba..c5870c3 100644 --- a/public/config.js +++ b/public/config.js @@ -1,6 +1,6 @@ +// Default development config — local-dev's infra-up.sh and the production +// deploy.yml regenerate this file with environment-specific values. globalThis.__DASHBOARD_CONFIG__ = { environment: "development", - stellarNetwork: "testnet", - rpcUrl: "https://soroban-testnet.stellar.org", - councilPlatformUrl: "http://localhost:3115", + networkDashboardPlatformUrl: "http://localhost:8080", }; diff --git a/public/world-map.svg b/public/world-map.svg deleted file mode 100644 index 10435de..0000000 --- a/public/world-map.svg +++ /dev/null @@ -1,836 +0,0 @@ - - Simple World Map - Author: Al MacDonald Editor: Fritz Lekschas License: CC BY-SA 3.0 ID: ISO 3166-1 or "_[a-zA-Z]" if an ISO code is not available - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/app-styles.css b/src/app-styles.css index 9b09f21..00805e1 100644 --- a/src/app-styles.css +++ b/src/app-styles.css @@ -1,179 +1,282 @@ /* - * App-specific styles for network-dashboard. + * Network Dashboard — v1 styles. * - * 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 ship from @moonlight/ui at build time (see + * src/build.ts). This file holds the 3-zone layout + zone-specific + * selectors that aren't part of the shared lib. */ -/* Nav-brand hover (lib's nav.css doesn't ship this variant) */ -.nav-brand:hover { - opacity: 0.9; +body { + margin: 0; + font-family: var(--font-family, system-ui, sans-serif); + color: var(--text, #1a1a1a); + background: var(--bg, #f8f9fa); } -/* Tables */ -table { - width: 100%; - border-collapse: collapse; - margin: 1rem 0; +#app { + min-height: 100vh; } -th, td { - padding: 0.75rem 1rem; - text-align: left; - border-bottom: 1px solid var(--border); +/* ── overall dashboard layout ─────────────────────────────────────── */ + +.dashboard { + display: grid; + grid-template-columns: 1fr 300px; + grid-template-rows: auto 1fr auto; + grid-template-areas: + "counters counters" + "topology feed" + "footer footer"; + gap: 1rem; + padding: 1.5rem; + max-width: 1440px; + margin: 0 auto; + min-height: 100vh; + box-sizing: border-box; } -th { - color: var(--text-muted); - font-size: 0.75rem; - text-transform: uppercase; - letter-spacing: 0.05em; +.zone { + border: 1px solid #333; + border-radius: 8px; + background: #fff; } -/* Utility */ -.text-muted { - color: var(--text-muted); +/* ── Zone 1: counter strip ────────────────────────────────────────── */ + +.counter-strip { + grid-area: counters; + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 1rem; + padding: 1rem; + background: transparent; + border: none; } -/* Loading + clickable row */ -.loading { - color: var(--text-muted); - padding: 2rem 0; +.counter-cell { + border: 2px solid #1971c2; + background: #e7f5ff; + border-radius: 8px; + padding: 1.25rem 1rem; + text-align: center; } -.clickable-row { - cursor: pointer; - transition: background 0.15s; +.counter-value { + font-size: 2.5rem; + font-weight: 700; + color: #1971c2; + line-height: 1.1; } -.clickable-row:hover { - background: var(--surface); +.counter-label { + margin-top: 0.25rem; + font-size: 0.75rem; + font-weight: 600; + letter-spacing: 0.08em; + color: #495057; } -/* Map */ -.map-container { - background: var(--surface); - border: 1px solid var(--border); - border-radius: 8px; +/* ── Zone 2: topology ─────────────────────────────────────────────── */ + +.topology { + grid-area: topology; padding: 1rem; - margin: 1rem 0 2rem; - overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + min-height: 600px; } -.world-map { +.topology-svg { width: 100%; - height: auto; + height: 100%; + max-height: 660px; } -.council-dot { - fill: var(--primary); - opacity: 0.8; - cursor: pointer; - transition: opacity 0.15s, r 0.15s; +.topology-label-center { + font-weight: 700; + font-size: 14px; + fill: #212529; + letter-spacing: 0.08em; } -.council-dot:hover { - opacity: 1; - fill: var(--primary-hover); +.topology-label-council { + font-size: 12px; + fill: #1f5e2c; + font-weight: 600; } -.council-label { - fill: var(--text-muted); +.topology-pp-count { font-size: 10px; - text-anchor: middle; - pointer-events: none; + fill: #1f5e2c; } -/* Council grid */ -.council-grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); - gap: 1rem; - margin-top: 1rem; +.topology-jurisdictions { + font-size: 10px; + fill: #495057; + font-weight: 500; + letter-spacing: 0.04em; } -.council-card { - background: var(--surface); - border: 1px solid var(--border); - border-radius: 8px; - padding: 1.25rem; - text-decoration: none; - color: var(--text); - transition: border-color 0.15s; +.topology-pulse { + opacity: 0.95; + animation: pulse-fade 1s ease-out forwards; } -.council-card:hover { - border-color: var(--primary); +@keyframes pulse-fade { + 0% { + opacity: 0.95; + transform: scale(0.6); + transform-origin: center; + } + 100% { + opacity: 0; + transform: scale(1.8); + transform-origin: center; + } } -.council-card-header { +/* ── Zone 3: activity feed ────────────────────────────────────────── */ + +.activity-feed { + grid-area: feed; + width: 280px; + padding: 0.75rem; display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 0.5rem; + flex-direction: column; + gap: 0.5rem; + overflow: hidden; } -.council-name { - font-weight: 600; +.activity-feed-header { + display: flex; + justify-content: space-between; + align-items: baseline; + padding-bottom: 0.5rem; + border-bottom: 1px solid #dee2e6; } -.council-card-meta { +.activity-feed-title { + font-weight: 600; font-size: 0.875rem; - color: var(--text-muted); - margin-bottom: 0.25rem; } -.council-card-id { +.activity-feed-status { font-size: 0.7rem; - color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.06em; + color: #868e96; } -/* Transaction feed */ -.feed-list { +.activity-feed-status[data-status="open"] { + color: #2f9e44; +} + +.activity-feed-list { display: flex; flex-direction: column; gap: 0.5rem; - margin-top: 1rem; + overflow: hidden; } -.feed-item { - background: var(--surface); - border: 1px solid var(--border); +.activity-card { border-radius: 8px; - padding: 0.75rem 1rem; + padding: 0.6rem 0.75rem; + border: 1px solid transparent; + animation: activity-fade 9s ease forwards; +} + +.activity-card--council_formed { + background: #f3d9fa; + border-color: #9775fa; + color: #5f3dc4; +} + +.activity-card--provider_added { + background: #d3f9d8; + border-color: #2f9e44; + color: #1f5e2c; +} + +.activity-card--provider_removed { + background: #e9ecef; + border-color: #868e96; + color: #495057; +} + +.activity-card--asset_registered { + background: #fff3bf; + border-color: #f59f00; + color: #5c3d00; +} + +.activity-card--channel_deposit { + background: #ffe8cc; + border-color: #e8590c; + color: #6a2a04; +} + +.activity-card--channel_settlement { + background: #d0ebff; + border-color: #1c7ed6; + color: #0c4a89; } -.feed-item-header { +.activity-card--channel_bundle { + background: #c5f6fa; + border-color: #15aabf; + color: #0b525e; +} + +.activity-card-row { display: flex; align-items: center; - gap: 0.75rem; - margin-bottom: 0.25rem; + gap: 0.5rem; + font-weight: 600; + font-size: 0.875rem; } -.feed-event-type { - font-weight: 500; - font-size: 0.875rem; +.activity-icon { + font-size: 1rem; + line-height: 1; } -.feed-item-details { - font-size: 0.8rem; - margin-bottom: 0.25rem; +.activity-council { + margin-top: 0.15rem; + font-size: 0.85rem; } -.feed-item-id { +.activity-detail { + margin-top: 0.15rem; font-size: 0.7rem; - color: var(--text-muted); + opacity: 0.75; } -/* 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; +.activity-card--leaving { + opacity: 0; + transition: opacity 0.5s ease; +} + +@keyframes activity-fade { + 0%, + 88% { + opacity: 1; + } + 100% { + opacity: 0; + } +} + +.mono { + font-family: ui-monospace, SFMono-Regular, "SF Mono", monospace; +} + +/* ── footer ───────────────────────────────────────────────────────── */ + +.dashboard-footer { + grid-area: footer; + text-align: center; + font-size: 0.75rem; + color: #868e96; + padding-top: 0.5rem; } diff --git a/src/app.ts b/src/app.ts index e00f669..b169283 100644 --- a/src/app.ts +++ b/src/app.ts @@ -1,34 +1,69 @@ -import { navigate, route, startRouter } from "./lib/router.ts"; -import { mapView } from "./views/map.ts"; -import { councilsView } from "./views/councils.ts"; -import { councilDetailView } from "./views/council-detail.ts"; -import { transactionsView } from "./views/transactions.ts"; - -route("/map", mapView); -route("/councils", councilsView); -route("/council/:id", councilDetailView); -route("/transactions", transactionsView); - -route("/", () => { - navigate("/map"); - return document.createElement("div"); -}); - -route("/404", () => { - const el = document.createElement("div"); - el.className = "login-container"; - el.innerHTML = - `

404

Page not found.

Back to dashboard
`; - return el; -}); - -startRouter(); - -// Dev-mode version check — __DEV_MODE__ is false in production, esbuild removes the block -import { checkVersions } from "./lib/version-check.ts"; -declare const __DEV_MODE__: boolean; -if (__DEV_MODE__) { - checkVersions().then((banner) => { - if (banner) document.body.prepend(banner); +/** + * Network-dashboard SPA entry. Single page, no router, no auth. + * + * Renders the 3-zone layout per the locked design sketch and pipes + * frames from the public WebSocket to the right zone. + */ +import { NETWORK_DASHBOARD_PLATFORM_URL } from "./lib/config.ts"; +import { connectNetworkPlatform } from "./lib/ws-client.ts"; +import { CounterStrip } from "./views/counter-strip.ts"; +import { Topology } from "./views/topology.ts"; +import { ActivityFeed } from "./views/activity-feed.ts"; + +declare const __APP_VERSION__: string; + +function renderShell(): { + layout: HTMLElement; + counters: CounterStrip; + topology: Topology; + feed: ActivityFeed; +} { + const app = document.getElementById("app"); + if (!app) throw new Error("#app root not found"); + app.textContent = ""; + + const layout = document.createElement("div"); + layout.className = "dashboard"; + + const counters = new CounterStrip(); + const topology = new Topology(); + const feed = new ActivityFeed(); + + layout.append(counters.element(), topology.element(), feed.element()); + + const footer = document.createElement("footer"); + footer.className = "dashboard-footer"; + footer.textContent = `Moonlight Network Dashboard · v${__APP_VERSION__}`; + layout.appendChild(footer); + + app.appendChild(layout); + return { layout, counters, topology, feed }; +} + +function bootstrap() { + const { counters, topology, feed } = renderShell(); + + if (!NETWORK_DASHBOARD_PLATFORM_URL) { + feed.setStatus("closed"); + return; + } + + connectNetworkPlatform(NETWORK_DASHBOARD_PLATFORM_URL, { + onStatusChange: (status) => feed.setStatus(status), + onSnapshot: (frame) => { + counters.render(frame.counters); + topology.render(frame.topology); + feed.seed(frame.recent); + }, + onEvent: (event) => { + // Live counter bump: events/24h is the only counter that ticks on + // every event (the others are derived from topology + are updated by + // the next snapshot tick at the hourly re-sync). + // We optimistically increment here for ticker-tape feel. + feed.append(event); + topology.pulse(event); + }, }); } + +bootstrap(); diff --git a/src/build.ts b/src/build.ts index 0b0183d..0fd86f8 100644 --- a/src/build.ts +++ b/src/build.ts @@ -15,7 +15,6 @@ const UI_LIB_TAG = "v0.3.1"; const UI_LIB_CSS_FILES = [ "tokens/tokens.css", "base-styles/base-styles.css", - "nav/nav.css", ]; async function writeHealthJson(version: string): Promise { @@ -54,22 +53,6 @@ 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,7 +64,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..2c0b342 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,26 +1,15 @@ /** - * Dashboard configuration. - * Reads from global config object set in index.html or defaults. + * Runtime config for the SPA. Populated at build time by + * `.github/workflows/deploy.yml` (testnet vs mainnet) and rewritten by + * `local-dev`'s `infra-up.sh` for the local stack. The browser reads + * `globalThis.__DASHBOARD_CONFIG__` set in `public/config.js` (loaded + * before `app.js`). */ -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; + environment?: "development" | "production"; + /** Base URL of the network-dashboard-platform service (no trailing slash). */ + networkDashboardPlatformUrl?: string; } declare global { @@ -29,102 +18,25 @@ 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 +const cfg = "__DASHBOARD_CONFIG__" in globalThis ? (globalThis as Record) .__DASHBOARD_CONFIG__ as DashboardConfig : undefined; -if (!config && typeof document !== "undefined") { - console.warn( - "Dashboard config not found — using testnet defaults. Ensure config.js loads before app.js.", - ); -} - -const c = config ?? {}; -export const ENVIRONMENT = c.environment ?? "development"; +export const ENVIRONMENT = cfg?.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. + * Network-dashboard-platform URL. In production this is set by the deploy + * pipeline. In local-dev it falls back to `http://localhost:8080` so a + * developer running `deno task serve` on the canonical port still works. */ -export function getCouncils(): Promise { - if (councilsCache) return councilsCache; +export const NETWORK_DASHBOARD_PLATFORM_URL: string = + cfg?.networkDashboardPlatformUrl?.replace(/\/+$/, "") ?? + (IS_PRODUCTION ? "" : "http://localhost:8080"); - 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; +if (!NETWORK_DASHBOARD_PLATFORM_URL && typeof document !== "undefined") { + console.warn( + "networkDashboardPlatformUrl not configured — dashboard will not connect to the network feed.", + ); } 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/dom_test.ts b/src/lib/dom_test.ts deleted file mode 100644 index 2c84e7e..0000000 --- a/src/lib/dom_test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { assertEquals } from "@std/assert"; -import { formatAmount, sanitizeUrl, timeAgo, truncateAddress } from "./dom.ts"; - -Deno.test("truncateAddress shortens long addresses", () => { - const addr = "CAF7DFHTPSYIW5543WBXJODZCDI5WF5SSHBXGMPKFOYPFRDVWFDNBGX7"; - assertEquals(truncateAddress(addr), "CAF7DF...BGX7"); -}); - -Deno.test("truncateAddress returns short strings unchanged", () => { - assertEquals(truncateAddress("SHORT"), "SHORT"); -}); - -Deno.test("formatAmount converts stroops to human readable", () => { - assertEquals(formatAmount(10_000_000n), "1.00"); - assertEquals(formatAmount(0n), "0.00"); - assertEquals(formatAmount(50_000_000_000n), "5,000.00"); -}); - -Deno.test("formatAmount handles large values without precision loss", () => { - // 100 billion XLM in stroops — above Number.MAX_SAFE_INTEGER - const huge = 1_000_000_000_000_000_000n; - const result = formatAmount(huge); - assertEquals(result.includes("100,000,000,000"), true); -}); - -Deno.test("timeAgo returns relative time", () => { - const now = Date.now() / 1000; - assertEquals(timeAgo(now - 30), "30s ago"); - assertEquals(timeAgo(now - 120), "2m ago"); - assertEquals(timeAgo(now - 7200), "2h ago"); - assertEquals(timeAgo(now - 172800), "2d ago"); -}); - -Deno.test("sanitizeUrl allows https URLs", () => { - assertEquals(sanitizeUrl("https://example.com"), "https://example.com/"); -}); - -Deno.test("sanitizeUrl rejects javascript: protocol", () => { - assertEquals(sanitizeUrl("javascript:alert(1)"), null); -}); - -Deno.test("sanitizeUrl rejects invalid URLs", () => { - assertEquals(sanitizeUrl("not a url"), null); -}); 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..9a27d38 --- /dev/null +++ b/src/lib/network-events.ts @@ -0,0 +1,62 @@ +/** + * Wire-frame types for the network-dashboard-platform WebSocket stream. + * Must stay in sync with `network-dashboard-platform/src/core/events/types.ts`. + */ + +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; +}; + +export type SnapshotFrame = { + type: "snapshot"; + counters: Counters; + topology: CouncilTopologyEntry[]; + recent: NetworkEvent[]; + generatedAt: string; +}; + +export type LiveFrame = { + type: "event"; + event: NetworkEvent; +}; + +export type ServerFrame = SnapshotFrame | LiveFrame; + +export const NETWORK_WS_SUBPROTOCOL = "moonlight.network.v1"; 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..21d9215 --- /dev/null +++ b/src/lib/ws-client.ts @@ -0,0 +1,121 @@ +import { + NETWORK_WS_SUBPROTOCOL, + type NetworkEvent, + type ServerFrame, + type SnapshotFrame, +} from "./network-events.ts"; + +export type WsHandlers = { + onSnapshot: (frame: SnapshotFrame) => void; + onEvent: (event: NetworkEvent) => void; + onStatusChange?: (status: "connecting" | "open" | "closed") => void; +}; + +export type WsHandle = { close: () => void }; + +const INITIAL_BACKOFF_MS = 1_000; +const MAX_BACKOFF_MS = 30_000; + +function isRecord(v: unknown): v is Record { + return typeof v === "object" && v !== null; +} + +function parseFrame(raw: string): ServerFrame | null { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (!isRecord(parsed)) return null; + if (parsed.type === "snapshot" && Array.isArray(parsed.topology)) { + return parsed as ServerFrame; + } + if (parsed.type === "event" && isRecord(parsed.event)) { + return parsed as ServerFrame; + } + return null; +} + +export function connectNetworkPlatform( + baseUrl: string, + handlers: WsHandlers, +): WsHandle { + const url = baseUrl.replace(/^http/, "ws").replace(/\/+$/, "") + + "/api/v1/network/ws"; + let socket: WebSocket | null = null; + let backoff = INITIAL_BACKOFF_MS; + let reconnectTimer: number | null = null; + let stopped = false; + + const setStatus = (s: "connecting" | "open" | "closed") => { + handlers.onStatusChange?.(s); + }; + + const open = () => { + if (stopped) return; + setStatus("connecting"); + try { + socket = new WebSocket(url, [NETWORK_WS_SUBPROTOCOL]); + } catch (err) { + console.warn("[network-ws] WebSocket constructor threw:", err); + scheduleReconnect(); + return; + } + socket.onopen = () => { + backoff = INITIAL_BACKOFF_MS; + setStatus("open"); + }; + socket.onmessage = (ev) => { + const frame = parseFrame(typeof ev.data === "string" ? ev.data : ""); + if (!frame) { + console.warn("[network-ws] dropping unparseable frame"); + return; + } + if (frame.type === "snapshot") { + handlers.onSnapshot(frame); + } else { + handlers.onEvent(frame.event); + } + }; + socket.onerror = () => { + // onclose handles reconnect + }; + socket.onclose = () => { + setStatus("closed"); + socket = null; + scheduleReconnect(); + }; + }; + + const scheduleReconnect = () => { + if (stopped || reconnectTimer !== null) return; + const delay = backoff; + backoff = Math.min(backoff * 2, MAX_BACKOFF_MS); + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + open(); + }, delay) as unknown as number; + }; + + open(); + + return { + close: () => { + stopped = true; + if (reconnectTimer !== null) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + if (socket) { + try { + socket.close(); + } catch { /* best effort */ } + socket = null; + } + }, + }; +} + +/** Exported for tests. */ +export const __testing = { parseFrame }; diff --git a/src/lib/ws-client_test.ts b/src/lib/ws-client_test.ts new file mode 100644 index 0000000..ce1c2c0 --- /dev/null +++ b/src/lib/ws-client_test.ts @@ -0,0 +1,48 @@ +import { assertEquals } from "@std/assert"; +import { __testing } from "./ws-client.ts"; + +const { parseFrame } = __testing; + +Deno.test("parseFrame accepts snapshot with topology array", () => { + const f = parseFrame(JSON.stringify({ + type: "snapshot", + counters: { + councils: 0, + activePPs: 0, + eventsLast24h: 0, + assetsRegistered: 0, + }, + topology: [], + recent: [], + generatedAt: new Date(0).toISOString(), + })); + assertEquals(f?.type, "snapshot"); +}); + +Deno.test("parseFrame accepts event frame", () => { + const f = parseFrame(JSON.stringify({ + type: "event", + event: { + id: "a", + kind: "provider_added", + councilId: "C", + councilName: null, + ledger: 1, + occurredAt: new Date(0).toISOString(), + payload: {}, + }, + })); + assertEquals(f?.type, "event"); +}); + +Deno.test("parseFrame rejects unknown types", () => { + assertEquals(parseFrame(JSON.stringify({ type: "ping" })), null); +}); + +Deno.test("parseFrame rejects malformed JSON", () => { + assertEquals(parseFrame("not-json"), null); +}); + +Deno.test("parseFrame rejects snapshot missing topology", () => { + assertEquals(parseFrame(JSON.stringify({ type: "snapshot" })), null); +}); diff --git a/src/views/activity-feed.ts b/src/views/activity-feed.ts new file mode 100644 index 0000000..46dd935 --- /dev/null +++ b/src/views/activity-feed.ts @@ -0,0 +1,181 @@ +import type { NetworkEvent, NetworkEventKind } from "../lib/network-events.ts"; +import { truncateAddress } from "../lib/dom.ts"; + +/** + * Zone 3 — right-rail activity feed. ~280px wide, newest card on top, + * each card fades out after ~8s (CSS animation). One card kind per + * NetworkEventKind; the design sketch fixes the colour palette. + */ + +const CARD_TTL_MS = 8_000; +const MAX_VISIBLE = 5; + +const KIND_GLYPH: Record = { + council_formed: "★", + provider_added: "✓", + provider_removed: "✗", + asset_registered: "+", + channel_deposit: "↙", + channel_settlement: "↗", + channel_bundle: "•", +}; + +const KIND_TITLE: Record = { + council_formed: "New council formed", + provider_added: "PP joined", + provider_removed: "PP left", + asset_registered: "New asset", + channel_deposit: "Deposit", + channel_settlement: "Settlement", + channel_bundle: "Bundle", +}; + +function detailFor(event: NetworkEvent): string { + const p = event.payload as Record; + switch (event.kind) { + case "provider_added": + case "provider_removed": + return typeof p.providerPublicKey === "string" + ? truncateAddress(p.providerPublicKey) + : ""; + case "channel_deposit": + case "channel_settlement": + return typeof p.amount === "string" ? `${p.amount} stroops` : ""; + 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)}` + : ""; + } +} + +function councilLabel(event: NetworkEvent): string { + const name = event.councilName?.trim(); + if (name) return name; + return `Council ${truncateAddress(event.councilId)}`; +} + +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 = "zone 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"; + this.statusEl = document.createElement("span"); + this.statusEl.className = "activity-feed-status"; + this.statusEl.dataset.status = "connecting"; + this.statusEl.textContent = "Connecting…"; + header.append(title, this.statusEl); + this.root.appendChild(header); + + this.list = document.createElement("div"); + this.list.className = "activity-feed-list"; + this.root.appendChild(this.list); + } + + element(): HTMLElement { + return this.root; + } + + setStatus(status: "connecting" | "open" | "closed"): void { + this.statusEl.dataset.status = status; + this.statusEl.textContent = status === "open" + ? "Live" + : status === "connecting" + ? "Connecting…" + : "Reconnecting…"; + } + + /** Seed from the snapshot frame. Replaces any existing cards. */ + seed(events: NetworkEvent[]): void { + for (const id of this.timers.values()) clearTimeout(id); + this.timers.clear(); + this.seen.clear(); + this.list.textContent = ""; + // Snapshot's `recent` is newest-first; we want the newest at the top + // of the list, so iterate oldest-first and `prepend` each. + for (const e of [...events].reverse()) { + this.append(e); + } + } + + append(event: NetworkEvent): void { + if (this.seen.has(event.id)) return; + this.seen.add(event.id); + + const card = document.createElement("article"); + card.className = `activity-card activity-card--${event.kind}`; + card.dataset.id = event.id; + + const row = document.createElement("div"); + row.className = "activity-card-row"; + const icon = document.createElement("span"); + icon.className = "activity-icon"; + icon.textContent = KIND_GLYPH[event.kind]; + const titleSpan = document.createElement("span"); + titleSpan.className = "activity-title"; + titleSpan.textContent = KIND_TITLE[event.kind]; + row.append(icon, titleSpan); + card.appendChild(row); + + const council = document.createElement("div"); + council.className = "activity-council"; + council.textContent = councilLabel(event); + card.appendChild(council); + + const detail = document.createElement("div"); + detail.className = "activity-detail mono"; + const detailText = detailFor(event); + if (detailText) detail.textContent = detailText; + card.appendChild(detail); + + this.list.prepend(card); + + while (this.list.children.length > MAX_VISIBLE) { + const oldest = this.list.lastElementChild as HTMLElement | null; + if (!oldest) break; + this.removeCard(oldest.dataset.id ?? ""); + } + + const timer = setTimeout(() => this.removeCard(event.id), CARD_TTL_MS); + this.timers.set(event.id, timer as unknown as number); + } + + private removeCard(id: string): void { + if (!id) return; + const node = this.list.querySelector( + `[data-id="${CSS.escape(id)}"]`, + ) as HTMLElement | null; + if (node) { + node.classList.add("activity-card--leaving"); + setTimeout(() => node.remove(), 500); + } + const t = this.timers.get(id); + if (t !== undefined) { + clearTimeout(t); + this.timers.delete(id); + } + } + + destroy(): void { + for (const id of this.timers.values()) clearTimeout(id); + this.timers.clear(); + } +} 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/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..c26cb2d --- /dev/null +++ b/src/views/counter-strip.ts @@ -0,0 +1,56 @@ +import type { Counters } from "../lib/network-events.ts"; + +/** + * Zone 1 — top counter strip. Four boxes per the design sketch (blue + * outline `#1971c2` / fill `#e7f5ff`), labels: COUNCILS / ACTIVE PPs / + * EVENTS / 24H / ASSETS REGISTERED. + */ + +const LABELS = { + councils: "COUNCILS", + activePPs: "ACTIVE PPs", + eventsLast24h: "EVENTS / 24H", + assetsRegistered: "ASSETS REGISTERED", +} as const; + +const KEYS: ReadonlyArray = [ + "councils", + "activePPs", + "eventsLast24h", + "assetsRegistered", +]; + +export class CounterStrip { + private root: HTMLElement; + private values: Record; + + constructor() { + this.root = document.createElement("section"); + this.root.className = "zone counter-strip"; + const cells: Partial> = {}; + for (const key of KEYS) { + const cell = document.createElement("div"); + cell.className = "counter-cell"; + const value = document.createElement("div"); + value.className = "counter-value"; + value.textContent = "—"; + const label = document.createElement("div"); + label.className = "counter-label"; + label.textContent = LABELS[key]; + cell.append(value, label); + this.root.appendChild(cell); + cells[key] = value; + } + this.values = cells as Record; + } + + element(): HTMLElement { + return this.root; + } + + render(counters: Counters): void { + for (const key of KEYS) { + this.values[key].textContent = counters[key].toLocaleString(); + } + } +} 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/topology.ts b/src/views/topology.ts new file mode 100644 index 0000000..b308cf4 --- /dev/null +++ b/src/views/topology.ts @@ -0,0 +1,273 @@ +import type { + CouncilTopologyEntry, + NetworkEvent, +} from "../lib/network-events.ts"; + +/** + * Zone 2 — topology. Yellow MOONLIGHT center with councils on a fixed ring, + * PP satellites around each council. Tx pulses animate along edges on + * each event (~1s fade). + * + * SVG-based, no physics; positions are recomputed only when the topology + * frame changes (snapshot or hourly re-sync). PP satellites are placed + * on an arc outside each council node, pointing radially away from + * Moonlight. + */ + +const SVG_NS = "http://www.w3.org/2000/svg"; + +const VIEW_W = 800; +const VIEW_H = 600; +const CX = VIEW_W / 2; +const CY = VIEW_H / 2; + +const MOON_RX = 70; +const MOON_RY = 36; +const COUNCIL_BASE_R = 22; +const PP_R = 8; +const COUNCIL_ORBIT = 200; +const PP_ORBIT_OFFSET = 50; + +const PULSE_FADE_MS = 1_000; + +const KIND_PULSE_COLOR: Record = { + council_formed: "#9775fa", // purple + provider_added: "#2f9e44", // green + provider_removed: "#868e96", // gray + asset_registered: "#f59f00", // amber + channel_deposit: "#e8590c", // orange + channel_settlement: "#1c7ed6", // blue + channel_bundle: "#15aabf", // teal +}; + +const DEFAULT_PULSE_COLOR = "#f03e3e"; // red + +type Layout = { + councils: Map; + pps: Map; +}; + +export class Topology { + private root: HTMLElement; + private svg: SVGSVGElement; + private edgesLayer: SVGGElement; + private nodesLayer: SVGGElement; + private pulsesLayer: SVGGElement; + private layout: Layout = { councils: new Map(), pps: new Map() }; + + constructor() { + this.root = document.createElement("section"); + this.root.className = "zone topology"; + + this.svg = document.createElementNS(SVG_NS, "svg"); + this.svg.setAttribute("viewBox", `0 0 ${VIEW_W} ${VIEW_H}`); + this.svg.setAttribute("preserveAspectRatio", "xMidYMid meet"); + this.svg.classList.add("topology-svg"); + + this.edgesLayer = this.makeGroup("edges"); + this.nodesLayer = this.makeGroup("nodes"); + this.pulsesLayer = this.makeGroup("pulses"); + + this.svg.append(this.edgesLayer, this.nodesLayer, this.pulsesLayer); + this.root.appendChild(this.svg); + } + + element(): HTMLElement { + return this.root; + } + + render(topology: CouncilTopologyEntry[]): void { + this.edgesLayer.textContent = ""; + this.nodesLayer.textContent = ""; + this.layout = { councils: new Map(), pps: new Map() }; + + // Moonlight center + const center = document.createElementNS(SVG_NS, "ellipse"); + center.setAttribute("cx", `${CX}`); + center.setAttribute("cy", `${CY}`); + center.setAttribute("rx", `${MOON_RX}`); + center.setAttribute("ry", `${MOON_RY}`); + center.setAttribute("fill", "#fff3bf"); + center.setAttribute("stroke", "#000"); + center.setAttribute("stroke-width", "3"); + this.nodesLayer.appendChild(center); + + const centerLabel = document.createElementNS(SVG_NS, "text"); + centerLabel.setAttribute("x", `${CX}`); + centerLabel.setAttribute("y", `${CY + 5}`); + centerLabel.setAttribute("text-anchor", "middle"); + centerLabel.classList.add("topology-label-center"); + centerLabel.textContent = "MOONLIGHT"; + this.nodesLayer.appendChild(centerLabel); + + if (topology.length === 0) return; + + const angleStep = (Math.PI * 2) / topology.length; + for (let i = 0; i < topology.length; i++) { + const council = topology[i]; + const angle = -Math.PI / 2 + i * angleStep; + const cx = CX + Math.cos(angle) * COUNCIL_ORBIT; + const cy = CY + Math.sin(angle) * COUNCIL_ORBIT; + const r = Math.min(COUNCIL_BASE_R + council.providers.length, 40); + this.layout.councils.set(council.id, { x: cx, y: cy, r }); + + // edge: moonlight → council + this.drawEdge(CX, CY, cx, cy); + + // council node + const node = document.createElementNS(SVG_NS, "circle"); + node.setAttribute("cx", `${cx}`); + node.setAttribute("cy", `${cy}`); + node.setAttribute("r", `${r}`); + node.setAttribute("fill", "#d3f9d8"); + node.setAttribute("stroke", "#2f9e44"); + node.setAttribute("stroke-width", "2"); + // Native SVG tooltip on hover — full public council info. + const tooltip = document.createElementNS(SVG_NS, "title"); + const ppList = council.providers + .map((p) => ` ${p.label ?? "(unlabelled)"} — ${p.publicKey}`) + .join("\n"); + const jurList = council.jurisdictions.length + ? council.jurisdictions.join(", ") + : "(none declared)"; + tooltip.textContent = `${council.name ?? "Council"} +Council ID: ${council.id} +Jurisdictions: ${jurList} +Providers (${council.providers.length}): +${ppList || " (none)"}`; + node.appendChild(tooltip); + this.nodesLayer.appendChild(node); + + const label = document.createElementNS(SVG_NS, "text"); + label.setAttribute("x", `${cx}`); + label.setAttribute("y", `${cy + r + 16}`); + label.setAttribute("text-anchor", "middle"); + label.classList.add("topology-label-council"); + label.textContent = council.name ?? "Council"; + this.nodesLayer.appendChild(label); + + // Jurisdiction badges below the name. + if (council.jurisdictions.length > 0) { + const juris = document.createElementNS(SVG_NS, "text"); + juris.setAttribute("x", `${cx}`); + juris.setAttribute("y", `${cy + r + 30}`); + juris.setAttribute("text-anchor", "middle"); + juris.classList.add("topology-jurisdictions"); + juris.textContent = council.jurisdictions.join(" · "); + this.nodesLayer.appendChild(juris); + } + + const ppCountLabel = document.createElementNS(SVG_NS, "text"); + ppCountLabel.setAttribute("x", `${cx}`); + ppCountLabel.setAttribute("y", `${cy + 4}`); + ppCountLabel.setAttribute("text-anchor", "middle"); + ppCountLabel.classList.add("topology-pp-count"); + ppCountLabel.textContent = `${council.providers.length} PP${ + council.providers.length === 1 ? "" : "s" + }`; + this.nodesLayer.appendChild(ppCountLabel); + + // PP satellites + const ppCount = council.providers.length; + if (ppCount === 0) continue; + const fanSpan = Math.PI / 2; // 90° fan outward + const fanStep = ppCount === 1 ? 0 : fanSpan / (ppCount - 1); + const baseAngle = angle - fanSpan / 2; + for (let j = 0; j < ppCount; j++) { + const pp = council.providers[j]; + const ppAngle = baseAngle + j * fanStep; + const px = cx + Math.cos(ppAngle) * PP_ORBIT_OFFSET; + const py = cy + Math.sin(ppAngle) * PP_ORBIT_OFFSET; + + this.drawEdge(cx, cy, px, py, 0.5); + + const ppNode = document.createElementNS(SVG_NS, "circle"); + ppNode.setAttribute("cx", `${px}`); + ppNode.setAttribute("cy", `${py}`); + ppNode.setAttribute("r", `${PP_R}`); + ppNode.setAttribute("fill", "#ffe8cc"); + ppNode.setAttribute("stroke", "#e8590c"); + ppNode.setAttribute("stroke-width", "2"); + const ppTip = document.createElementNS(SVG_NS, "title"); + ppTip.textContent = `${pp.label ?? "(unlabelled PP)"} +${pp.publicKey} +on ${council.name ?? "Council"}`; + ppNode.appendChild(ppTip); + this.nodesLayer.appendChild(ppNode); + + this.layout.pps.set(pp.publicKey, { + x: px, + y: py, + councilId: council.id, + }); + } + } + } + + /** + * Spawn a tx pulse for the given event. Placement depends on event kind: + * - council-scoped events pulse the moonlight↔council edge midpoint + * - PP-scoped events pulse the council↔PP edge midpoint + * - asset / deposit / settlement pulse the moonlight↔council edge + * + * Missing-target events (council not in current layout) are silently + * dropped — the next snapshot will reconcile and the event remains in + * the activity feed regardless. + */ + pulse(event: NetworkEvent): void { + const council = this.layout.councils.get(event.councilId); + if (!council) return; + const colour = KIND_PULSE_COLOR[event.kind] ?? DEFAULT_PULSE_COLOR; + + // PP events pulse on the council↔PP edge if we can resolve the PP. + if ( + (event.kind === "provider_added" || event.kind === "provider_removed") && + typeof event.payload.providerPublicKey === "string" + ) { + const pp = this.layout.pps.get(event.payload.providerPublicKey); + if (pp && pp.councilId === event.councilId) { + this.spawnPulse((council.x + pp.x) / 2, (council.y + pp.y) / 2, colour); + return; + } + } + // Default: pulse the moonlight↔council edge midpoint. + this.spawnPulse((CX + council.x) / 2, (CY + council.y) / 2, colour); + } + + private spawnPulse(x: number, y: number, colour: string): void { + const pulse = document.createElementNS(SVG_NS, "circle"); + pulse.setAttribute("cx", `${x}`); + pulse.setAttribute("cy", `${y}`); + pulse.setAttribute("r", "7"); + pulse.setAttribute("fill", colour); + pulse.setAttribute("stroke", colour); + pulse.setAttribute("stroke-width", "2"); + pulse.classList.add("topology-pulse"); + this.pulsesLayer.appendChild(pulse); + setTimeout(() => pulse.remove(), PULSE_FADE_MS); + } + + private drawEdge( + x1: number, + y1: number, + x2: number, + y2: number, + opacity = 0.35, + ): void { + const line = document.createElementNS(SVG_NS, "line"); + line.setAttribute("x1", `${x1}`); + line.setAttribute("y1", `${y1}`); + line.setAttribute("x2", `${x2}`); + line.setAttribute("y2", `${y2}`); + line.setAttribute("stroke", "#adb5bd"); + line.setAttribute("stroke-width", "1"); + line.setAttribute("opacity", `${opacity}`); + this.edgesLayer.appendChild(line); + } + + private makeGroup(name: string): SVGGElement { + const g = document.createElementNS(SVG_NS, "g"); + g.setAttribute("data-layer", name); + return g as SVGGElement; + } +} 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("") - } -
- `; -}