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 @@
-
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 =
- `
`;
- 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!)}`
- : ""
- }
-
- `;
-
- 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
-
-
-
- | Channel ID |
- Asset |
- Supply |
-
-
-
- ${
- channelData.map((ch) => `
-
- | ${truncateAddress(ch.id)} |
- ${escapeHtml(ch.asset)} |
- ${formatAmount(ch.supply)} |
-
- `).join("")
- }
-
-
-
- ${
- activeProviders.length > 0
- ? `
- Registered Providers
-
-
-
- | Provider Address |
-
-
-
- ${
- activeProviders.map((p) => `
-
- | ${escapeHtml(p)} |
-
- `).join("")
- }
-
-
- `
- : `
- Registered Providers
- No providers discovered from recent on-chain events.
- `
- }
-
- Recent Activity
- ${
- allEvents.length > 0
- ? `
-
- ${
- allEvents.slice(0, 50).map((e) => `
-
-
-
${
- 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.
-
- `;
- 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
-
-
-
-
-
-
- | Council |
- Jurisdiction |
- Channels |
- Providers |
- Total Supply |
- Status |
-
-
-
- ${
- states.map((s) => {
- const supply = s.channels.reduce((sum, c) => sum + c.supply, 0n);
- return `
-
- |
- ${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 |
-
- `;
- }).join("")
- }
-
-
- `;
-
- 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.
-
- Councils by Jurisdiction
-
- `;
- 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) => `
-
-
-
- ${
- 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 = `
-
- `;
- } 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.
-
- `;
- 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) => `
-
-
-
- Council: ${
- escapeHtml(entry.councilName)
- }
- ${
- entry.channelAsset !== "\u2014"
- ? `Asset: ${
- escapeHtml(entry.channelAsset)
- }`
- : ""
- }
-
-
${
- truncateAddress(entry.channelId)
- }
-
- `).join("")
- }
-
- `;
-}