diff --git a/.eslintrc.js b/.eslintrc.js index fd789b60..e718e3e0 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,10 +1,24 @@ module.exports = { - extends: ["@stellar/eslint-config"], + env: { + browser: true, + es2021: true, + node: true, + }, + extends: ["eslint:recommended", "prettier"], + parser: "@typescript-eslint/parser", + parserOptions: { + ecmaVersion: 12, + sourceType: "module", + }, + plugins: ["@typescript-eslint"], rules: { "no-console": "off", - "import/no-unresolved": "off", - "no-await-in-loop": "off", "no-constant-condition": "off", - "@typescript-eslint/naming-convention": ["warn"], + "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], + "no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + "prefer-const": "warn", + "no-var": "warn", + eqeqeq: "warn", }, + ignorePatterns: ["node_modules/", "dist/", "*.min.js"], }; diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index b6a074d4..cdf82e85 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -1,12 +1,12 @@ -name: "CodeQL" +name: "CodeQL Security Analysis" on: push: - branches: [ "master" ] + branches: ["master", "main"] pull_request: - branches: [ "master" ] + branches: ["master", "main"] schedule: - - cron: '26 17 * * 6' + - cron: "26 17 * * 6" jobs: analyze: @@ -16,26 +16,31 @@ jobs: permissions: # required for all workflows security-events: write + # required to fetch internal or private CodeQL packs + packages: read + # only required for workflows in private repositories + actions: read + contents: read strategy: fail-fast: false matrix: include: - - language: javascript-typescript - build-mode: none - + - language: javascript-typescript + build-mode: none + steps: - - name: Checkout repository - uses: actions/checkout@v4 + - name: Checkout repository + uses: actions/checkout@v4 - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v3 - with: - languages: ${{ matrix.language }} - build-mode: ${{ matrix.build-mode }} + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v3 - with: - category: "/language:${{matrix.language}}" + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{matrix.language}}" diff --git a/.github/workflows/test-build.yml b/.github/workflows/test-build.yml index 29b9b2f8..68167368 100644 --- a/.github/workflows/test-build.yml +++ b/.github/workflows/test-build.yml @@ -1,29 +1,55 @@ -name: Test and build +name: Test and Build + on: push: branches: - - master + - master + - main pull_request: jobs: - build: + test-and-build: runs-on: ubuntu-latest + services: redis: - image: redis + image: redis:7-alpine # Set health checks to wait until redis has started options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 + --health-cmd "redis-cli ping" --health-interval 10s --health-timeout + 5s --health-retries 5 ports: - - 6379:6379 + - 6379:6379 + steps: - - uses: actions/checkout@v2 - - uses: actions/setup-node@v2 + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "22" + cache: "npm" + + - name: Install dependencies + run: npm install --legacy-peer-deps + # Note: Using --legacy-peer-deps due to muicss package compatibility with React 18 + + - name: Run linter + run: npx eslint backend/ --ext .ts + + - name: Run tests + run: npm test + env: + DEV: true + + - name: Build application + run: npm run build + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + if: success() with: - node-version: 16 - - run: yarn install - - run: yarn test - - run: yarn build + name: build-files + path: dist/ + retention-days: 7 diff --git a/.gitignore b/.gitignore index 9bd8a66e..9e180427 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,10 @@ /node_modules -/.tmp /dist *.eslintcache *service-account.json *.env +/.vscode +/.idea +/.kiro +/.cursor +dump.rdb diff --git a/.prettierignore b/.prettierignore index d36977dc..8b137891 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1 +1 @@ -.tmp + diff --git a/Dockerfile b/Dockerfile index 133f538e..3126152d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ubuntu:22.04 +FROM ubuntu:24.04 MAINTAINER SDF Ops Team @@ -8,16 +8,20 @@ WORKDIR /app/src RUN apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \ gpg curl ca-certificates git apt-transport-https && \ curl -sSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key|gpg --dearmor >/etc/apt/trusted.gpg.d/nodesource-key.gpg && \ - echo "deb https://deb.nodesource.com/node_16.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list && \ - curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg |gpg --dearmor >/etc/apt/trusted.gpg.d/yarnpkg.gpg && \ - echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list && \ - apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs yarn && \ - yarn install && /app/src/node_modules/gulp/bin/gulp.js build + echo "deb https://deb.nodesource.com/node_22.x nodistro main" | tee /etc/apt/sources.list.d/nodesource.list && \ + apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs && \ + npm ci --legacy-peer-deps && npm run build ENV PORT=80 UPDATE_DATA=false EXPOSE 80 RUN node_modules/typescript/bin/tsc +# Copy common directory to dist for runtime access +RUN cp -r common dist/ + +# Change working directory to dist for runtime +WORKDIR /app/src/dist + ENTRYPOINT ["/usr/bin/node"] CMD ["./backend/app.js"] diff --git a/Procfile b/Procfile index e1d4131b..f9a9b5c6 100644 --- a/Procfile +++ b/Procfile @@ -1 +1 @@ -web: node app.js +web: npx tsx backend/app.ts diff --git a/README.md b/README.md index 44e5ff1c..73862087 100644 --- a/README.md +++ b/README.md @@ -4,19 +4,19 @@ To build this project, you must have the following dependencies installed: -- node 10.16.3 -- yarn +- Node v22 +- NPM ## Installation ```sh -yarn +npm install ``` ## Developing ```sh -yarn start +npm run dev ``` ### If you wish to use backend server API, you need to have redis running locally on port 6379 (default for redis) @@ -27,7 +27,7 @@ yarn start brew install redis ``` -(Other install directions can be found here: https://redis.io/download) +(Other install directions can be found [here](https://redis.io/download)) Make sure it's running @@ -38,17 +38,25 @@ brew services start redis Once you have redis installed, start this command ```sh -yarn run start:backend +npm run start:backend ``` It will create a proxy to `browser-sync` server started by gulp at `http://localhost:5000` ### Connecting to Big Query -Connecting to Big Query is not required for running the backend (if you run with UPDATE_DATA=false), but is required for things like catching up ledger data in redis. -This project is pulling from SDF's `crypto-stellar` public data set, so no special credentials are required. However you will need a Google Cloud Platform project with a service account to be able to access Big Query. +Connecting to Big Query is not required for running the backend (if you run with +UPDATE_DATA=false), but is required for things like catching up ledger data in +redis. -Directions for creating a service account [can be found here](https://cloud.google.com/docs/authentication/getting-started). +This project is pulling from SDF's `crypto-stellar` public data set, so no +special credentials are required. However you will need a Google Cloud Platform +project with a service account to be able to access Big Query. -Once you've created a service account, add the service account key json file to the `gcloud` folder under the name `service-account.json`. An example json file shows what the file structure should look like. +Directions for creating a service account +[can be found here](https://cloud.google.com/docs/authentication/getting-started). + +Once you've created a service account, add the service account key json file to +the `gcloud` folder under the name `service-account.json`. An example json file +shows what the file structure should look like. diff --git a/backend/app.ts b/backend/app.ts index fdaf3ba4..18c4e095 100644 --- a/backend/app.ts +++ b/backend/app.ts @@ -1,8 +1,3 @@ -// need to manually import regeneratorRuntime for babel w/ async -// https://github.com/babel/babel/issues/9849#issuecomment-487040428 -// require("regenerator-runtime/runtime"); -import "regenerator-runtime/runtime"; - import "dotenv/config"; // Run backend with cache updates. diff --git a/backend/ledgers.ts b/backend/ledgers.ts index 54328f6c..fcf06fd7 100644 --- a/backend/ledgers.ts +++ b/backend/ledgers.ts @@ -1,4 +1,4 @@ -import stellarSdk from "stellar-sdk"; +import { Horizon } from "@stellar/stellar-sdk"; import { findIndex } from "lodash"; import { Response, NextFunction } from "express"; @@ -64,7 +64,7 @@ export async function updateLedgers() { await catchup(REDIS_LEDGER_KEY, pagingToken, REDIS_PAGING_TOKEN_KEY, 0); - const horizon = new stellarSdk.Server("https://horizon.stellar.org"); + const horizon = new Horizon.Server("https://horizon.stellar.org"); horizon .ledgers() .cursor(CURSOR_NOW) @@ -82,7 +82,7 @@ export async function catchup( pagingTokenKey: string, limit: number, // if 0, catchup until now ) { - const horizon = new stellarSdk.Server("https://horizon.stellar.org"); + const horizon = new Horizon.Server("https://horizon.stellar.org"); let ledgers: LedgerRecord[] = []; let total = 0; let pagingToken = pagingTokenStart; diff --git a/backend/routes.ts b/backend/routes.ts index d3b2d1ce..57b62e0b 100644 --- a/backend/routes.ts +++ b/backend/routes.ts @@ -1,6 +1,8 @@ import express from "express"; import proxy from "express-http-proxy"; import logger from "morgan"; +import path from "path"; +import rateLimit from "express-rate-limit"; import * as lumens from "./lumens"; import * as lumensV2V3 from "./v2v3/lumens"; @@ -12,7 +14,66 @@ app.set("json spaces", 2); app.use(logger("combined")); +// Global rate limiting for all requests (including static files) +const globalLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, // 15 minutes + max: 1000, // Limit each IP to 1000 requests per windowMs + message: { + error: "Too Many Requests", + message: "Too many requests from this IP, please try again later.", + retryAfter: 900, // 15 minutes in seconds + }, + standardHeaders: true, + legacyHeaders: false, + skip: () => process.env.DEV === "true", +}); + +// Apply global rate limiting to all requests +app.use(globalLimiter); + +// Rate limiting configuration +const createRateLimit = (windowMs: number, max: number, message: string) => { + return rateLimit({ + windowMs, + max, + message: { + error: "Too Many Requests", + message, + retryAfter: Math.ceil(windowMs / 1000), + }, + standardHeaders: true, // Return rate limit info in the `RateLimit-*` headers + legacyHeaders: false, // Disable the `X-RateLimit-*` headers + // Skip rate limiting in development + skip: () => process.env.DEV === "true", + }); +}; + +// General API rate limit: 100 requests per 15 minutes +const generalApiLimiter = createRateLimit( + 15 * 60 * 1000, // 15 minutes + 100, + "Too many API requests from this IP, please try again later.", +); + +// Strict rate limit for resource-intensive endpoints: 20 requests per 5 minutes +const strictApiLimiter = createRateLimit( + 5 * 60 * 1000, // 5 minutes + 20, + "This endpoint is rate limited due to high resource usage. Please try again later.", +); + +// Very strict rate limit for external service endpoints (CoinMarketCap): 10 requests per minute +const externalServiceLimiter = createRateLimit( + 60 * 1000, // 1 minute + 10, + "This endpoint is heavily rate limited. Please cache responses and avoid frequent requests.", +); + +// Apply general rate limiting to all API routes +app.use("/api/", generalApiLimiter); + if (process.env.DEV) { + // Development: proxy to Vite dev server app.use( "/", proxy("localhost:3000", { @@ -24,26 +85,137 @@ if (process.env.DEV) { }), ); } else { - app.use(express.static("dist")); + // Production: serve built static files + // Determine the correct static directory based on environment + let staticDir: string; + + if (process.cwd().endsWith("/dist")) { + // Docker environment: already in dist directory + staticDir = "."; + } else { + // Heroku/other environments: serve from dist directory + staticDir = path.join(__dirname, "..", "..", "dist"); + } + + console.log(`Serving static files from: ${path.resolve(staticDir)}`); + + // Verify the static directory exists and contains expected files + try { + const fs = require("fs"); + const indexPath = path.join(staticDir, "index.html"); + if (!fs.existsSync(indexPath)) { + console.error(`ERROR: index.html not found at ${indexPath}`); + console.error( + 'Make sure to run "npm run build" before starting the server', + ); + process.exit(1); + } + } catch (error) { + console.error("ERROR: Unable to verify static directory:", error); + process.exit(1); + } + + // Serve static files with security headers and restrictions + app.use( + express.static(staticDir, { + // Security options + dotfiles: "deny", // Don't serve hidden files (.env, .git, etc.) + index: "index.html", + maxAge: "1d", // Cache static assets for 1 day + // Restrict to specific file extensions for security + extensions: [ + "html", + "js", + "css", + "png", + "jpg", + "jpeg", + "gif", + "ico", + "svg", + "woff", + "woff2", + "ttf", + "eot", + ], + setHeaders: (res, filePath) => { + // Add security headers + res.setHeader("X-Content-Type-Options", "nosniff"); + res.setHeader("X-Frame-Options", "DENY"); + res.setHeader("X-XSS-Protection", "1; mode=block"); + res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); + + // Set appropriate cache headers based on file type + if (filePath.endsWith(".html")) { + res.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); + res.setHeader("Pragma", "no-cache"); + res.setHeader("Expires", "0"); + } else if ( + filePath.match( + /\.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$/, + ) + ) { + res.setHeader("Cache-Control", "public, max-age=86400, immutable"); // 1 day with immutable + } + }, + }), + ); + + // Fallback to index.html for SPA routing (must come after API routes) + app.get("*", (req, res, next) => { + // Skip API routes + if (req.path.startsWith("/api/")) { + return next(); + } + + // Only serve index.html for GET requests to prevent issues with other HTTP methods + if (req.method !== "GET") { + return next(); + } + + // Serve index.html for all other routes (SPA routing) + const indexPath = path.join(path.resolve(staticDir), "index.html"); + res.sendFile(indexPath, (err) => { + if (err) { + console.error("Error serving index.html:", err); + res.status(500).send("Internal Server Error"); + } + }); + }); } -app.get("/api/ledgers/public", ledgers.handler); +// API Routes with appropriate rate limiting +app.get("/api/ledgers/public", strictApiLimiter, ledgers.handler); app.get("/api/lumens", lumens.v1Handler); app.get("/api/v2/lumens", lumensV2V3.v2Handler); -/* For CoinMarketCap */ -app.get("/api/v2/lumens/total-supply", lumensV2V3.v2TotalSupplyHandler); +/* For CoinMarketCap - heavily rate limited */ +app.get( + "/api/v2/lumens/total-supply", + externalServiceLimiter, + lumensV2V3.v2TotalSupplyHandler, +); app.get( "/api/v2/lumens/circulating-supply", + externalServiceLimiter, lumensV2V3.v2CirculatingSupplyHandler, ); app.get("/api/v3/lumens", lumensV2V3.v3Handler); -app.get("/api/v3/lumens/all", lumensV2V3.totalSupplyCheckHandler); -/* For CoinMarketCap */ -app.get("/api/v3/lumens/total-supply", lumensV2V3.v3TotalSupplyHandler); +app.get( + "/api/v3/lumens/all", + strictApiLimiter, + lumensV2V3.totalSupplyCheckHandler, +); +/* For CoinMarketCap - heavily rate limited */ +app.get( + "/api/v3/lumens/total-supply", + externalServiceLimiter, + lumensV2V3.v3TotalSupplyHandler, +); app.get( "/api/v3/lumens/circulating-supply", + externalServiceLimiter, lumensV2V3.v3CirculatingSupplyHandler, ); diff --git a/common/lumens.d.ts b/common/lumens.d.ts index 04b573a4..6ffd48c4 100644 --- a/common/lumens.d.ts +++ b/common/lumens.d.ts @@ -1,14 +1,18 @@ -export var ORIGINAL_SUPPLY_AMOUNT: string; -export function getLumenBalance(horizonURL: string, accountId: string): string; -export function totalLumens(horizonURL: string): string; +export const ORIGINAL_SUPPLY_AMOUNT: string; +export function getLumenBalance( + horizonURL: string, + accountId: string, +): Promise; +export function totalLumens(horizonURL: string): Promise; export function inflationLumens(): Promise; -export function feePool(): string; -export function burnedLumens(): string; +export function feePool(): Promise; +export function burnedLumens(): Promise; export function directDevelopmentAll(): Promise; export function distributionEcosystemSupport(): Promise; export function distributionUseCaseInvestment(): Promise; export function distributionUserAcquisition(): Promise; -export function getUpgradeReserve(): string; +export function distributionAll(): Promise; +export function getUpgradeReserve(): Promise; export function sdfAccounts(): Promise; export function totalSupply(): Promise; export function noncirculatingSupply(): Promise; diff --git a/common/lumens.js b/common/lumens.js index a1b32585..a79616f0 100644 --- a/common/lumens.js +++ b/common/lumens.js @@ -1,10 +1,10 @@ // This file contains functions used both in backend and frontend code. // Will be helpful to build distribution stats API. -const axios = require("axios"); -const BigNumber = require("bignumber.js"); -const map = require("lodash/map"); -const reduce = require("lodash/reduce"); -const find = require("lodash/find"); +import axios from "axios"; +import BigNumber from "bignumber.js"; +import map from "lodash/map.js"; +import reduce from "lodash/reduce.js"; +import find from "lodash/find.js"; const horizonLiveURL = "https://horizon.stellar.org"; @@ -47,85 +47,74 @@ const accounts = { "GBI5PADO5TEDY3R6WFAO2HEKBTTZS4LGR77XM4AHGN52H45ENBWGDFOH", }; -const ORIGINAL_SUPPLY_AMOUNT = "100000000000"; +export const ORIGINAL_SUPPLY_AMOUNT = "100000000000"; -exports.ORIGINAL_SUPPLY_AMOUNT = ORIGINAL_SUPPLY_AMOUNT; - -exports.getLumenBalance = getLumenBalance; -function getLumenBalance(horizonURL, accountId) { - return axios - .get(`${horizonURL}/accounts/${accountId}`) - .then((response) => { - var xlmBalance = find( - response.data.balances, - (b) => b.asset_type == "native", +export async function getLumenBalance(horizonURL, accountId) { + try { + const response = await axios.get(`${horizonURL}/accounts/${accountId}`); + const xlmBalance = find( + response.data.balances, + (b) => b.asset_type === "native", + ); + return xlmBalance.balance; + } catch (error) { + if ( + error.response && + (error.response.status === 404 || error.response.status === 400) + ) { + console.warn( + `Account ${accountId} not found or invalid (${error.response.status}), treating as 0 balance`, ); - return xlmBalance.balance; - }) - .catch((error) => { - if (error.response && error.response.status == 404) { - return "0.0"; // consider the balance of an account zero if the account does not exist or has been deleted from the network - } else throw error; // something else happened, and at this point we shouldn't trust the computed balance - }); -} - -function sumRelevantAccounts(accounts) { - return Promise.all( - accounts.map((acct) => getLumenBalance(horizonLiveURL, acct)), - ).then((data) => - data - .reduce( - (sum, currentBalance) => new BigNumber(currentBalance).plus(sum), - new BigNumber(0), - ) - .toString(), + return "0.0"; // consider the balance of an account zero if the account does not exist, has been deleted, or is invalid + } else { + console.error(`Error fetching balance for account ${accountId}:`, error); + throw error; // something else happened, and at this point we shouldn't trust the computed balance + } + } +} + +async function sumRelevantAccounts(accountList) { + const balances = await Promise.all( + accountList.map((acct) => getLumenBalance(horizonLiveURL, acct)), ); + return balances + .reduce( + (sum, currentBalance) => new BigNumber(currentBalance).plus(sum), + new BigNumber(0), + ) + .toString(); } -exports.totalLumens = totalLumens; -function totalLumens(horizonURL) { - return axios - .get(`${horizonURL}/ledgers/?order=desc&limit=1`) - .then((response) => { - return response.data._embedded.records[0].total_coins; - }); +export async function totalLumens(horizonURL) { + const response = await axios.get(`${horizonURL}/ledgers/?order=desc&limit=1`); + return response.data._embedded.records[0].total_coins; } -exports.inflationLumens = inflationLumens; -function inflationLumens() { - return Promise.all([ +export async function inflationLumens() { + const [totalLumensValue, originalSupply] = await Promise.all([ totalLumens(horizonLiveURL), ORIGINAL_SUPPLY_AMOUNT, - ]).then((result) => { - let [totalLumens, originalSupply] = result; - return new BigNumber(totalLumens).minus(originalSupply); - }); -} - -exports.feePool = feePool; -function feePool() { - return axios - .get(`${horizonLiveURL}/ledgers/?order=desc&limit=1`) - .then((response) => { - return response.data._embedded.records[0].fee_pool; - }); -} - -exports.burnedLumens = burnedLumens; -function burnedLumens() { - return axios - .get(`${horizonLiveURL}/accounts/${voidAccount}`) - .then((response) => { - var xlmBalance = find( - response.data.balances, - (b) => b.asset_type == "native", - ); - return xlmBalance.balance; - }); + ]); + return new BigNumber(totalLumensValue).minus(originalSupply); +} + +export async function feePool() { + const response = await axios.get( + `${horizonLiveURL}/ledgers/?order=desc&limit=1`, + ); + return response.data._embedded.records[0].fee_pool; +} + +export async function burnedLumens() { + const response = await axios.get(`${horizonLiveURL}/accounts/${voidAccount}`); + const xlmBalance = find( + response.data.balances, + (b) => b.asset_type === "native", + ); + return xlmBalance.balance; } -exports.directDevelopmentAll = directDevelopmentAll; -function directDevelopmentAll() { +export async function directDevelopmentAll() { const { directDevelopment, // directDevelopmentHot1, @@ -144,8 +133,7 @@ function directDevelopmentAll() { ]); } -exports.distributionEcosystemSupport = distributionEcosystemSupport; -function distributionEcosystemSupport() { +export async function distributionEcosystemSupport() { const { infrastructureGrants, currencySupport, @@ -162,14 +150,12 @@ function distributionEcosystemSupport() { ]); } -exports.distributionUseCaseInvestment = distributionUseCaseInvestment; -function distributionUseCaseInvestment() { +export async function distributionUseCaseInvestment() { const { enterpriseFund, newProducts } = accounts; return sumRelevantAccounts([enterpriseFund, newProducts]); } -exports.distributionUserAcquisition = distributionUserAcquisition; -function distributionUserAcquisition() { +export async function distributionUserAcquisition() { const { inAppDistribution, inAppDistributionHot, @@ -187,52 +173,62 @@ function distributionUserAcquisition() { ]); } -exports.getUpgradeReserve = getUpgradeReserve; -function getUpgradeReserve() { +export async function distributionAll() { + const results = await Promise.all([ + distributionEcosystemSupport(), + distributionUseCaseInvestment(), + distributionUserAcquisition(), + directDevelopmentAll(), + ]); + return results.reduce( + (sum, balance) => new BigNumber(sum).plus(balance), + new BigNumber(0), + ); +} + +export async function getUpgradeReserve() { return getLumenBalance(horizonLiveURL, networkUpgradeReserveAccount); } -exports.sdfAccounts = sdfAccounts; -function sdfAccounts() { - var balanceMap = map(accounts, (id) => getLumenBalance(horizonLiveURL, id)); - return Promise.all(balanceMap).then((balances) => { - return reduce( - balances, - (sum, balance) => sum.plus(balance), - new BigNumber(0), - ); - }); +export async function sdfAccounts() { + const balanceMap = map(accounts, (id) => getLumenBalance(horizonLiveURL, id)); + const balances = await Promise.all(balanceMap); + return reduce( + balances, + (sum, balance) => sum.plus(balance), + new BigNumber(0), + ); } -exports.totalSupply = totalSupply; -function totalSupply() { - return Promise.all([inflationLumens(), burnedLumens()]).then((result) => { - let [inflationLumens, burnedLumens] = result; +export async function totalSupply() { + const [inflationLumensValue, burnedLumensValue] = await Promise.all([ + inflationLumens(), + burnedLumens(), + ]); - return new BigNumber(ORIGINAL_SUPPLY_AMOUNT) - .plus(inflationLumens) - .minus(burnedLumens); - }); + return new BigNumber(ORIGINAL_SUPPLY_AMOUNT) + .plus(inflationLumensValue) + .minus(burnedLumensValue); } -exports.noncirculatingSupply = noncirculatingSupply; -function noncirculatingSupply() { - return Promise.all([getUpgradeReserve(), feePool(), sdfAccounts()]).then( - (balances) => { - return reduce( - balances, - (sum, balance) => sum.plus(balance), - new BigNumber(0), - ); - }, +export async function noncirculatingSupply() { + const balances = await Promise.all([ + getUpgradeReserve(), + feePool(), + sdfAccounts(), + ]); + return reduce( + balances, + (sum, balance) => sum.plus(balance), + new BigNumber(0), ); } -exports.circulatingSupply = circulatingSupply; -function circulatingSupply() { - return Promise.all([totalSupply(), noncirculatingSupply()]).then((result) => { - let [totalLumens, noncirculatingSupply] = result; +export async function circulatingSupply() { + const [totalLumensValue, noncirculatingSupplyValue] = await Promise.all([ + totalSupply(), + noncirculatingSupply(), + ]); - return new BigNumber(totalLumens).minus(noncirculatingSupply); - }); + return new BigNumber(totalLumensValue).minus(noncirculatingSupplyValue); } diff --git a/frontend/app.js b/frontend/app.js deleted file mode 100644 index 3c396e91..00000000 --- a/frontend/app.js +++ /dev/null @@ -1,8 +0,0 @@ -import React from "react"; -import ReactDOM from "react-dom"; -import App from "./components/App.js"; - -require("./index.html"); -require("./scss/index.scss"); - -ReactDOM.render(, document.getElementById("app")); diff --git a/frontend/components/AccountBadge.js b/frontend/components/AccountBadge.jsx similarity index 100% rename from frontend/components/AccountBadge.js rename to frontend/components/AccountBadge.jsx diff --git a/frontend/components/AccountBalance.js b/frontend/components/AccountBalance.jsx similarity index 95% rename from frontend/components/AccountBalance.js rename to frontend/components/AccountBalance.jsx index 5b665201..9cc57492 100644 --- a/frontend/components/AccountBalance.js +++ b/frontend/components/AccountBalance.jsx @@ -1,5 +1,5 @@ import React from "react"; -import AmountWidget from "./AmountWidget"; +import AmountWidget from "./AmountWidget.jsx"; import Panel from "muicss/lib/react/panel"; import axios from "axios"; import find from "lodash/find"; diff --git a/frontend/components/AmountWidget.js b/frontend/components/AmountWidget.jsx similarity index 100% rename from frontend/components/AmountWidget.js rename to frontend/components/AmountWidget.jsx diff --git a/frontend/components/App.js b/frontend/components/App.jsx similarity index 86% rename from frontend/components/App.js rename to frontend/components/App.jsx index 5c160dc3..689f70ed 100644 --- a/frontend/components/App.js +++ b/frontend/components/App.jsx @@ -3,24 +3,25 @@ import Panel from "muicss/lib/react/panel"; import { EventEmitter } from "fbemitter"; import axios from "axios"; import moment from "moment"; -import { Server } from "stellar-sdk"; +import * as StellarSdk from "@stellar/stellar-sdk"; -import AppBar from "./AppBar"; -import AccountBalance from "./AccountBalance"; -import FeeStats from "./FeeStats"; -import NetworkStatus from "./NetworkStatus"; -import Incidents from "./Incidents"; -import LedgerCloseChart from "./LedgerCloseChart"; -import LumensCirculating from "./LumensCirculating"; -import LumensNonCirculating from "./LumensNonCirculating"; -import PublicNetworkLedgersHistoryChart from "./PublicNetworkLedgersHistoryChart"; -import RecentOperations from "./RecentOperations"; -import TotalCoins from "./TotalCoins"; -import TransactionsChart from "./TransactionsChart"; -import FailedTransactionsChart from "./FailedTransactionsChart"; +import AppBar from "./AppBar.jsx"; +import AccountBalance from "./AccountBalance.jsx"; +import FeeStats from "./FeeStats.jsx"; +import NetworkStatus from "./NetworkStatus.jsx"; +import Incidents from "./Incidents.jsx"; +// D3 components - now updated to D3 v7 +import LedgerCloseChart from "./LedgerCloseChart.jsx"; +import PublicNetworkLedgersHistoryChart from "./PublicNetworkLedgersHistoryChart.jsx"; +import TransactionsChart from "./TransactionsChart.jsx"; +import FailedTransactionsChart from "./FailedTransactionsChart.jsx"; +import LumensCirculating from "./LumensCirculating.jsx"; +import LumensNonCirculating from "./LumensNonCirculating.jsx"; +import RecentOperations from "./RecentOperations.jsx"; +import TotalCoins from "./TotalCoins.jsx"; import { LIVE_NEW_LEDGER, TEST_NEW_LEDGER } from "../events"; import { setTimeOffset } from "../common/time"; -import { ScheduledMaintenance } from "./ScheduledMaintenance"; +import { ScheduledMaintenance } from "./ScheduledMaintenance.jsx"; import sanitizeHtml from "../utilities/sanitizeHtml.js"; const horizonLive = "https://horizon.stellar.org"; @@ -134,17 +135,29 @@ export default class App extends React.Component { streamLedgers(horizonURL, eventName) { // Get last ledger - axios.get(`${horizonURL}/ledgers?order=desc&limit=1`).then((response) => { - let lastLedger = response.data._embedded.records[0]; + axios + .get(`${horizonURL}/ledgers?order=desc&limit=1`) + .then((response) => { + let lastLedger = response.data._embedded.records[0]; - new Server(horizonURL) - .ledgers() - .cursor(lastLedger.paging_token) - .limit(200) - .stream({ - onmessage: (ledger) => this.emitter.emit(eventName, ledger), - }); - }); + new StellarSdk.Horizon.Server(horizonURL) + .ledgers() + .cursor(lastLedger.paging_token) + .limit(200) + .stream({ + onmessage: (ledger) => this.emitter.emit(eventName, ledger), + onerror: (error) => { + console.warn("Stellar streaming error:", error); + // Retry after 5 seconds + setTimeout(() => this.streamLedgers(horizonURL, eventName), 5000); + }, + }); + }) + .catch((error) => { + console.warn("Failed to get initial ledger:", error); + // Retry after 5 seconds + setTimeout(() => this.streamLedgers(horizonURL, eventName), 5000); + }); } turnOffForceTheme() { diff --git a/frontend/components/AppBar.js b/frontend/components/AppBar.jsx similarity index 100% rename from frontend/components/AppBar.js rename to frontend/components/AppBar.jsx diff --git a/frontend/components/AssetLink.js b/frontend/components/AssetLink.jsx similarity index 88% rename from frontend/components/AssetLink.js rename to frontend/components/AssetLink.jsx index f73cee5e..d3108788 100644 --- a/frontend/components/AssetLink.js +++ b/frontend/components/AssetLink.jsx @@ -1,5 +1,5 @@ import React from "react"; -import AccountBadge from "./AccountBadge"; +import AccountBadge from "./AccountBadge.jsx"; export default class AssetLink extends React.Component { render() { diff --git a/frontend/components/D3BarChart.jsx b/frontend/components/D3BarChart.jsx new file mode 100644 index 00000000..56fdbe22 --- /dev/null +++ b/frontend/components/D3BarChart.jsx @@ -0,0 +1,150 @@ +import React, { useEffect, useRef } from "react"; +import * as d3 from "d3"; + +export default function D3BarChart({ + data, + width = 400, + height = 120, + margin = { top: 10, right: 10, bottom: 30, left: 50 }, + colorScale, + tickFormat, +}) { + const svgRef = useRef(); + + useEffect(() => { + if (!data || data.length === 0) return; + + const svg = d3.select(svgRef.current); + svg.selectAll("*").remove(); // Clear previous render + + const innerWidth = width - margin.left - margin.right; + const innerHeight = height - margin.top - margin.bottom; + + // Flatten all values from all series for domain calculation + const allValues = data.flatMap((series) => series.values); + const xValues = allValues.map((d) => d.x); + const yValues = allValues.map((d) => d.y); + + // Create scales - use scalePoint for fixed spacing instead of scaleBand + const xScale = d3 + .scalePoint() + .domain(xValues) + .range([0, innerWidth]) + .padding(1.0); // Double the gap - more space from Y-axis + + const yScale = d3 + .scaleLinear() + .domain([0, d3.max(yValues)]) + .range([innerHeight, 0]); + + // Fixed bar dimensions + const fixedBarWidth = 5; // Fixed 5px bar width + const fixedGapWidth = 3; // Fixed 3px gap between bars + + // Create color scale - match original react-d3-components colors + const colors = + colorScale || + d3 + .scaleOrdinal() + .range([ + "#1f77b4", + "#ff7f0e", + "#2ca02c", + "#d62728", + "#9467bd", + "#8c564b", + "#e377c2", + "#7f7f7f", + "#bcbd22", + "#17becf", + ]); + + // Create main group + const g = svg + .attr("width", width) + .attr("height", height) + .append("g") + .attr("transform", `translate(${margin.left},${margin.top})`); + + // Calculate bar width - always use fixed width for all bars + const barWidth = fixedBarWidth; // All bars are exactly 5px wide + + // Add bars for each series + data.forEach((series, seriesIndex) => { + g.selectAll(`.bar-${seriesIndex}`) + .data(series.values) + .enter() + .append("rect") + .attr("class", `bar-${seriesIndex}`) + .attr("x", (d) => xScale(d.x) - fixedBarWidth / 2) // Center the bar on the scale point + .attr("y", (d) => yScale(d.y)) + .attr("width", barWidth) // Always 5px wide + .attr("height", (d) => innerHeight - yScale(d.y)) + .attr("fill", colors(seriesIndex)) + .style("shape-rendering", "crispEdges"); // Crisp edges like original + }); + + // Add x-axis with styling to match original + const xAxis = d3.axisBottom(xScale).tickSize(6).tickPadding(3); + + const xAxisGroup = g + .append("g") + .attr("class", "axis") + .attr("transform", `translate(0,${innerHeight})`) + .call(xAxis); + + // Style x-axis to match original + xAxisGroup + .selectAll("text") + .style("font-size", "10px") + .style("font-family", "sans-serif") + .style("fill", "#000"); + + xAxisGroup + .selectAll("line") + .style("stroke", "#000") + .style("shape-rendering", "crispEdges"); + + xAxisGroup + .select(".domain") + .style("stroke", "#000") + .style("shape-rendering", "crispEdges"); + + // Add y-axis with styling to match original + const yAxis = d3 + .axisLeft(yScale) + .tickSize(6) + .tickPadding(3) + .ticks(Math.min(7, Math.floor(d3.max(yValues)))) // Limit to max 7 ticks or the max value, whichever is smaller + .tickValues( + d3.range(0, Math.ceil(d3.max(yValues)) + 1).filter((d) => d % 1 === 0), + ); // Only integer values + + if (tickFormat) { + yAxis.tickFormat(tickFormat); + } else { + yAxis.tickFormat(d3.format("d")); // Format as integers (1, 2, 3) instead of decimals (1.0, 2.0) + } + + const yAxisGroup = g.append("g").attr("class", "axis").call(yAxis); + + // Style y-axis to match original + yAxisGroup + .selectAll("text") + .style("font-size", "10px") + .style("font-family", "sans-serif") + .style("fill", "#000"); + + yAxisGroup + .selectAll("line") + .style("stroke", "#000") + .style("shape-rendering", "crispEdges"); + + yAxisGroup + .select(".domain") + .style("stroke", "#000") + .style("shape-rendering", "crispEdges"); + }, [data, width, height, margin, colorScale, tickFormat]); + + return ; +} diff --git a/frontend/components/D3BarChartNoXLabels.jsx b/frontend/components/D3BarChartNoXLabels.jsx new file mode 100644 index 00000000..48a65f13 --- /dev/null +++ b/frontend/components/D3BarChartNoXLabels.jsx @@ -0,0 +1,207 @@ +import React, { useEffect, useRef } from "react"; +import * as d3 from "d3"; + +export default function D3BarChartNoXLabels({ + data, + width = 400, + height = 120, + margin = { top: 10, right: 10, bottom: 8, left: 50 }, // Reduced bottom margin since no X labels + colorScale, + tickFormat, + yAxisMax = 450, // Maximum Y value + yAxisStep = 50, // Y axis increment step +}) { + const svgRef = useRef(); + + useEffect(() => { + if (!data || data.length === 0) return; + + const svg = d3.select(svgRef.current); + svg.selectAll("*").remove(); // Clear previous render + + const innerWidth = width - margin.left - margin.right; + const innerHeight = height - margin.top - margin.bottom; + + // Flatten all values from all series for domain calculation + const allValues = data.flatMap((series) => series.values); + const xValues = allValues.map((d) => d.x); + const yValues = allValues.map((d) => d.y); + + // Create scales - use scalePoint for fixed spacing instead of scaleBand + const xScale = d3 + .scalePoint() + .domain(xValues) + .range([0, innerWidth]) + .padding(1.0); // Double the gap - more space from Y-axis + + const yScale = d3 + .scaleLinear() + .domain([0, yAxisMax]) // Use fixed max instead of data max + .range([innerHeight, 0]); + + // Fixed bar dimensions + const fixedBarWidth = 5; // Fixed 5px bar width + const fixedGapWidth = 3; // Fixed 3px gap between bars + + // Create color scale - match original react-d3-components colors + const colors = + colorScale || + d3 + .scaleOrdinal() + .range([ + "#1f77b4", + "#ff7f0e", + "#2ca02c", + "#d62728", + "#9467bd", + "#8c564b", + "#e377c2", + "#7f7f7f", + "#bcbd22", + "#17becf", + ]); + + // Create main group + const g = svg + .attr("width", width) + .attr("height", height) + .append("g") + .attr("transform", `translate(${margin.left},${margin.top})`); + + // Calculate bar width - always use fixed width for all bars + const barWidth = fixedBarWidth; // All bars are exactly 5px wide + + // Add bars for each series - create stacked bars + // First, we need to calculate the cumulative values for stacking + const stackedData = []; + + // Assume we have exactly 2 series for stacking + if (data.length === 2) { + const xValues = data[0].values.map((d) => d.x); + + xValues.forEach((x, index) => { + const bottomValue = data[0].values[index].y; // First series (bottom) + const topValue = data[1].values[index].y; // Second series (top) + + stackedData.push({ + x: x, + bottom: bottomValue, + top: topValue, + bottomHeight: bottomValue, + topHeight: topValue, + totalHeight: bottomValue + topValue, + }); + }); + + // Draw bottom bars (first series) + g.selectAll(".bar-bottom") + .data(stackedData) + .enter() + .append("rect") + .attr("class", "bar-bottom") + .attr("x", (d) => xScale(d.x) - fixedBarWidth / 2) + .attr("y", (d) => yScale(d.bottomHeight)) + .attr("width", barWidth) + .attr("height", (d) => innerHeight - yScale(d.bottomHeight)) + .attr("fill", colors(0)) + .style("shape-rendering", "crispEdges"); + + // Draw top bars (second series) - stacked on top of bottom bars + g.selectAll(".bar-top") + .data(stackedData) + .enter() + .append("rect") + .attr("class", "bar-top") + .attr("x", (d) => xScale(d.x) - fixedBarWidth / 2) + .attr("y", (d) => yScale(d.totalHeight)) + .attr("width", barWidth) + .attr("height", (d) => yScale(d.bottomHeight) - yScale(d.totalHeight)) + .attr("fill", colors(1)) + .style("shape-rendering", "crispEdges"); + } else { + // Fallback to original behavior for non-stacked charts + data.forEach((series, seriesIndex) => { + g.selectAll(`.bar-${seriesIndex}`) + .data(series.values) + .enter() + .append("rect") + .attr("class", `bar-${seriesIndex}`) + .attr("x", (d) => xScale(d.x) - fixedBarWidth / 2) + .attr("y", (d) => yScale(d.y)) + .attr("width", barWidth) + .attr("height", (d) => innerHeight - yScale(d.y)) + .attr("fill", colors(seriesIndex)) + .style("shape-rendering", "crispEdges"); + }); + } + + // Add x-axis with NO labels + const xAxis = d3 + .axisBottom(xScale) + .tickSize(6) + .tickPadding(3) + .tickFormat(""); // No labels + + const xAxisGroup = g + .append("g") + .attr("class", "axis") + .attr("transform", `translate(0,${innerHeight})`) + .call(xAxis); + + // Style x-axis lines only (no text) + xAxisGroup + .selectAll("line") + .style("stroke", "#000") + .style("shape-rendering", "crispEdges"); + + xAxisGroup + .select(".domain") + .style("stroke", "#000") + .style("shape-rendering", "crispEdges"); + + // Add y-axis with custom ticks + const yAxisTicks = d3.range(0, yAxisMax + yAxisStep, yAxisStep); // [0, 50, 100, 150, ..., yAxisMax] + + const yAxis = d3 + .axisLeft(yScale) + .tickSize(6) + .tickPadding(3) + .tickValues(yAxisTicks); + + if (tickFormat) { + yAxis.tickFormat(tickFormat); + } else { + yAxis.tickFormat(d3.format("d")); // Format as integers + } + + const yAxisGroup = g.append("g").attr("class", "axis").call(yAxis); + + // Style y-axis to match original + yAxisGroup + .selectAll("text") + .style("font-size", "10px") + .style("font-family", "sans-serif") + .style("fill", "#000"); + + yAxisGroup + .selectAll("line") + .style("stroke", "#000") + .style("shape-rendering", "crispEdges"); + + yAxisGroup + .select(".domain") + .style("stroke", "#000") + .style("shape-rendering", "crispEdges"); + }, [ + data, + width, + height, + margin, + colorScale, + tickFormat, + yAxisMax, + yAxisStep, + ]); + + return ; +} diff --git a/frontend/components/FailedTransactionsChart.js b/frontend/components/FailedTransactionsChart.js deleted file mode 100644 index a68141be..00000000 --- a/frontend/components/FailedTransactionsChart.js +++ /dev/null @@ -1,117 +0,0 @@ -import React from "react"; -import Panel from "muicss/lib/react/panel"; -import axios from "axios"; -import { scale, format } from "d3"; -import BarChart from "react-d3-components/lib/BarChart"; -import clone from "lodash/clone"; -import each from "lodash/each"; - -export default class FailedTransactionsChart extends React.Component { - constructor(props) { - super(props); - this.panel = null; - this.colorScale = scale.category10(); - this.state = { - loading: true, - chartWidth: 400, - chartHeigth: this.props.chartHeigth || 120, - }; - this.url = `${this.props.horizonURL}/ledgers?order=desc&limit=${this.props.limit}`; - } - - componentDidMount() { - this.getLedgers(); - // Update chart width - this.updateSize(); - setInterval(() => this.updateSize(), 5000); - } - - updateSize() { - let value = this.panel.offsetWidth - 20; - if (this.state.chartWidth != value) { - this.setState({ chartWidth: value }); - } - } - - onNewLedger(ledger) { - let data = clone(this.state.data); - data[0].values.push({ - x: ledger.sequence.toString(), - y: ledger.successful_transaction_count, - }); - data[1].values.push({ - x: ledger.sequence.toString(), - y: ledger.failed_transaction_count, - }); - data[0].values.shift(); - data[1].values.shift(); - this.setState({ loading: false, data }); - } - - getLedgers() { - axios.get(this.url).then((response) => { - let data = [ - { - label: "Success", - values: [], - }, - { - label: "Fail", - values: [], - }, - ]; - each(response.data._embedded.records, (ledger) => { - data[0].values.unshift({ - x: ledger.sequence.toString(), - y: ledger.successful_transaction_count, - }); - data[1].values.unshift({ - x: ledger.sequence.toString(), - y: ledger.failed_transaction_count, - }); - }); - this.setState({ loading: false, data }); - // Start listening to events - this.props.emitter.addListener( - this.props.newLedgerEventName, - this.onNewLedger.bind(this), - ); - }); - } - - render() { - return ( -
{ - this.panel = el; - }} - > - -
- - Successful - {" "} - &{" "} - Failed{" "} - Txs in the last {this.props.limit} ledgers: {this.props.network} - - API - -
- {this.state.loading ? ( - "Loading..." - ) : ( - - )} -
-
- ); - } -} diff --git a/frontend/components/FailedTransactionsChart.jsx b/frontend/components/FailedTransactionsChart.jsx new file mode 100644 index 00000000..34fc5dbc --- /dev/null +++ b/frontend/components/FailedTransactionsChart.jsx @@ -0,0 +1,197 @@ +import React from "react"; +import Panel from "muicss/lib/react/panel"; +import axios from "axios"; +import * as d3 from "d3"; +import D3BarChartNoXLabels from "./D3BarChartNoXLabels.jsx"; +import clone from "lodash/clone"; +import each from "lodash/each"; + +export default class FailedTransactionsChart extends React.Component { + constructor(props) { + super(props); + this.panel = null; + // Use the same colors as the original react-d3-components + this.colorScale = d3 + .scaleOrdinal() + .range([ + "#1f77b4", + "#ff7f0e", + "#2ca02c", + "#d62728", + "#9467bd", + "#8c564b", + "#e377c2", + "#7f7f7f", + "#bcbd22", + "#17becf", + ]); + this.state = { + loading: true, + chartWidth: 400, + chartHeight: this.props.chartHeight || 120, + yAxisMax: 300, // Default value, will be updated dynamically + yAxisStep: 100, // Default value, will be updated dynamically + }; + this.url = `${this.props.horizonURL}/ledgers?order=desc&limit=${this.props.limit}`; + } + + componentDidMount() { + this.getLedgers(); + // Update chart width + this.updateSize(); + setInterval(() => this.updateSize(), 5000); + } + + updateSize() { + let value = this.panel.offsetWidth - 20; + if (this.state.chartWidth != value) { + this.setState({ chartWidth: value }); + } + } + + calculateDynamicYAxisParams(data) { + // For stacked charts, we need to find the maximum combined value + let maxValue = 0; + + if (data.length === 2) { + // For stacked data, calculate the sum of both series at each point + const xValues = data[0].values.map((d) => d.x); + + xValues.forEach((x, index) => { + const bottomValue = data[0].values[index].y; + const topValue = data[1].values[index].y; + const combinedValue = bottomValue + topValue; + + if (combinedValue > maxValue) { + maxValue = combinedValue; + } + }); + } else { + // Fallback for non-stacked charts + data.forEach((series) => { + series.values.forEach((point) => { + if (point.y > maxValue) { + maxValue = point.y; + } + }); + }); + } + + // Determine step size based on network type + let stepSize; + if (this.props.network === "Test network") { + stepSize = 1; // Test network uses step size of 1 + } else { + // Live network: choose between 50 and 100 based on resulting tick count + const ticksWith50 = Math.ceil(maxValue / 50); + stepSize = ticksWith50 <= 10 ? 50 : 100; + } + + const yAxisMax = Math.ceil(maxValue / stepSize) * stepSize; + + // Ensure minimum values for better chart readability + let minYAxisMax; + if (this.props.network === "Test network") { + minYAxisMax = 10; // Smaller minimum for test network + } else { + minYAxisMax = stepSize === 50 ? 100 : 200; + } + + return { + yAxisMax: Math.max(yAxisMax, minYAxisMax), + yAxisStep: stepSize, + }; + } + + onNewLedger(ledger) { + let data = clone(this.state.data); + data[0].values.push({ + x: ledger.sequence.toString(), + y: ledger.successful_transaction_count, + }); + data[1].values.push({ + x: ledger.sequence.toString(), + y: ledger.failed_transaction_count, + }); + data[0].values.shift(); + data[1].values.shift(); + + // Calculate dynamic yAxisMax and yAxisStep based on data + const { yAxisMax, yAxisStep } = this.calculateDynamicYAxisParams(data); + + this.setState({ loading: false, data, yAxisMax, yAxisStep }); + } + + getLedgers() { + axios.get(this.url).then((response) => { + let data = [ + { + label: "Success", + values: [], + }, + { + label: "Fail", + values: [], + }, + ]; + each(response.data._embedded.records, (ledger) => { + data[0].values.unshift({ + x: ledger.sequence.toString(), + y: ledger.successful_transaction_count, + }); + data[1].values.unshift({ + x: ledger.sequence.toString(), + y: ledger.failed_transaction_count, + }); + }); + + // Calculate dynamic yAxisMax and yAxisStep based on data + const { yAxisMax, yAxisStep } = this.calculateDynamicYAxisParams(data); + + this.setState({ loading: false, data, yAxisMax, yAxisStep }); + // Start listening to events + this.props.emitter.addListener( + this.props.newLedgerEventName, + this.onNewLedger.bind(this), + ); + }); + } + + render() { + return ( +
{ + this.panel = el; + }} + > + +
+ + Successful + {" "} + &{" "} + Failed{" "} + Txs in the last {this.props.limit} ledgers: {this.props.network} + + API + +
+ {this.state.loading ? ( + "Loading..." + ) : ( + + )} +
+
+ ); + } +} diff --git a/frontend/components/FeeStats.js b/frontend/components/FeeStats.jsx similarity index 97% rename from frontend/components/FeeStats.js rename to frontend/components/FeeStats.jsx index 83128cf5..cb3302bc 100644 --- a/frontend/components/FeeStats.js +++ b/frontend/components/FeeStats.jsx @@ -6,8 +6,8 @@ import clone from "lodash/clone"; import each from "lodash/each"; import defaults from "lodash/defaults"; import get from "lodash/get"; -import AccountBadge from "./AccountBadge"; -import AssetLink from "./AssetLink"; +import AccountBadge from "./AccountBadge.jsx"; +import AssetLink from "./AssetLink.jsx"; import BigNumber from "bignumber.js"; import { ago } from "../common/time"; diff --git a/frontend/components/Incidents.js b/frontend/components/Incidents.jsx similarity index 100% rename from frontend/components/Incidents.js rename to frontend/components/Incidents.jsx diff --git a/frontend/components/LedgerCloseChart.js b/frontend/components/LedgerCloseChart.jsx similarity index 82% rename from frontend/components/LedgerCloseChart.js rename to frontend/components/LedgerCloseChart.jsx index 0ebe3d8e..f2ea24f0 100644 --- a/frontend/components/LedgerCloseChart.js +++ b/frontend/components/LedgerCloseChart.jsx @@ -1,8 +1,8 @@ import React from "react"; import Panel from "muicss/lib/react/panel"; import axios from "axios"; -import { scale } from "d3"; -import BarChart from "react-d3-components/lib/BarChart"; +import * as d3 from "d3"; +import D3BarChart from "./D3BarChart.jsx"; import each from "lodash/each"; import clone from "lodash/clone"; @@ -10,11 +10,25 @@ export default class LedgerChartClose extends React.Component { constructor(props) { super(props); this.panel = null; - this.colorScale = scale.category10(); + // Use the same colors as the original react-d3-components + this.colorScale = d3 + .scaleOrdinal() + .range([ + "#1f77b4", + "#ff7f0e", + "#2ca02c", + "#d62728", + "#9467bd", + "#8c564b", + "#e377c2", + "#7f7f7f", + "#bcbd22", + "#17becf", + ]); this.state = { loading: true, chartWidth: 400, - chartHeigth: this.props.chartHeigth || 120, + chartHeight: this.props.chartHeight || 120, }; this.url = `${this.props.horizonURL}/ledgers?order=desc&limit=${this.props.limit}`; } @@ -94,12 +108,12 @@ export default class LedgerChartClose extends React.Component { {this.state.loading ? ( "Loading..." ) : ( - )} diff --git a/frontend/components/LiquidityPoolBadge.js b/frontend/components/LiquidityPoolBadge.jsx similarity index 100% rename from frontend/components/LiquidityPoolBadge.js rename to frontend/components/LiquidityPoolBadge.jsx diff --git a/frontend/components/ListAccounts.js b/frontend/components/ListAccounts.jsx similarity index 98% rename from frontend/components/ListAccounts.js rename to frontend/components/ListAccounts.jsx index d3001343..3c96f0a8 100644 --- a/frontend/components/ListAccounts.js +++ b/frontend/components/ListAccounts.jsx @@ -4,7 +4,7 @@ import axios from "axios"; import clone from "lodash/clone"; import find from "lodash/find"; import reduce from "lodash/reduce"; -import AccountBadge from "./AccountBadge"; +import AccountBadge from "./AccountBadge.jsx"; import BigNumber from "bignumber.js"; export default class ListAccounts extends React.Component { diff --git a/frontend/components/LumensCirculating.js b/frontend/components/LumensCirculating.jsx similarity index 85% rename from frontend/components/LumensCirculating.js rename to frontend/components/LumensCirculating.jsx index 51a28912..93f0482c 100644 --- a/frontend/components/LumensCirculating.js +++ b/frontend/components/LumensCirculating.jsx @@ -1,8 +1,9 @@ import React from "react"; -import AmountWidget from "./AmountWidget"; +import AmountWidget from "./AmountWidget.jsx"; import BigNumber from "bignumber.js"; import Panel from "muicss/lib/react/panel"; -import { circulatingSupply } from "../../common/lumens.js"; +import * as lumens from "../../common/lumens.js"; +const { circulatingSupply } = lumens; export default class LumensCirculating extends AmountWidget { constructor(props) { diff --git a/frontend/components/LumensDistributed.js b/frontend/components/LumensDistributed.jsx similarity index 85% rename from frontend/components/LumensDistributed.js rename to frontend/components/LumensDistributed.jsx index 775c38be..d682ed2c 100644 --- a/frontend/components/LumensDistributed.js +++ b/frontend/components/LumensDistributed.jsx @@ -1,10 +1,11 @@ import React from "react"; -import AmountWidget from "./AmountWidget"; +import AmountWidget from "./AmountWidget.jsx"; import Panel from "muicss/lib/react/panel"; import BigNumber from "bignumber.js"; import axios from "axios"; import find from "lodash/find"; -import { distributionAll } from "../../common/lumens.js"; +import * as lumens from "../../common/lumens.js"; +const { distributionAll } = lumens; export default class LumensDistributed extends AmountWidget { constructor(props) { diff --git a/frontend/components/LumensNonCirculating.js b/frontend/components/LumensNonCirculating.jsx similarity index 84% rename from frontend/components/LumensNonCirculating.js rename to frontend/components/LumensNonCirculating.jsx index cf258429..d5089ee3 100644 --- a/frontend/components/LumensNonCirculating.js +++ b/frontend/components/LumensNonCirculating.jsx @@ -1,7 +1,8 @@ import React from "react"; -import AmountWidget from "./AmountWidget"; +import AmountWidget from "./AmountWidget.jsx"; import Panel from "muicss/lib/react/panel"; -import { noncirculatingSupply } from "../../common/lumens.js"; +import * as lumens from "../../common/lumens.js"; +const { noncirculatingSupply } = lumens; export default class LumensNonCirculating extends AmountWidget { constructor(props) { diff --git a/frontend/components/NetworkStatus.js b/frontend/components/NetworkStatus.jsx similarity index 100% rename from frontend/components/NetworkStatus.js rename to frontend/components/NetworkStatus.jsx diff --git a/frontend/components/PublicNetworkLedgersHistoryChart.js b/frontend/components/PublicNetworkLedgersHistoryChart.jsx similarity index 71% rename from frontend/components/PublicNetworkLedgersHistoryChart.js rename to frontend/components/PublicNetworkLedgersHistoryChart.jsx index bc91dab7..d20c02c5 100644 --- a/frontend/components/PublicNetworkLedgersHistoryChart.js +++ b/frontend/components/PublicNetworkLedgersHistoryChart.jsx @@ -1,8 +1,8 @@ import React from "react"; import Panel from "muicss/lib/react/panel"; import axios from "axios"; -import { scale, format } from "d3"; -import BarChart from "react-d3-components/lib/BarChart"; +import * as d3 from "d3"; +import D3BarChartNoXLabels from "./D3BarChartNoXLabels.jsx"; import clone from "lodash/clone"; import each from "lodash/each"; @@ -10,7 +10,21 @@ export default class PublicNetworkLedgersHistoryChart extends React.Component { constructor(props) { super(props); this.panel = null; - this.colorScale = scale.category10(); + // Use the same colors as the original react-d3-components + this.colorScale = d3 + .scaleOrdinal() + .range([ + "#1f77b4", + "#ff7f0e", + "#2ca02c", + "#d62728", + "#9467bd", + "#8c564b", + "#e377c2", + "#7f7f7f", + "#bcbd22", + "#17becf", + ]); this.state = { loading: true, chartWidth: 400, @@ -62,20 +76,22 @@ export default class PublicNetworkLedgersHistoryChart extends React.Component { >
- Txs &{" "} - Ops in + Txs &{" "} + Ops in the last 30 days: Live Network
{this.state.loading ? ( "Loading..." ) : ( - )}
diff --git a/frontend/components/RecentOperations.js b/frontend/components/RecentOperations.jsx similarity index 98% rename from frontend/components/RecentOperations.js rename to frontend/components/RecentOperations.jsx index a82600ab..42bc8e8a 100644 --- a/frontend/components/RecentOperations.js +++ b/frontend/components/RecentOperations.jsx @@ -4,9 +4,9 @@ import axios from "axios"; import moment from "moment"; import each from "lodash/each"; import defaults from "lodash/defaults"; -import AccountBadge from "./AccountBadge"; -import LiquidityPoolBadge from "./LiquidityPoolBadge"; -import AssetLink from "./AssetLink"; +import AccountBadge from "./AccountBadge.jsx"; +import LiquidityPoolBadge from "./LiquidityPoolBadge.jsx"; +import AssetLink from "./AssetLink.jsx"; import BigNumber from "bignumber.js"; import { ago } from "../common/time"; diff --git a/frontend/components/ScheduledMaintenance.js b/frontend/components/ScheduledMaintenance.jsx similarity index 100% rename from frontend/components/ScheduledMaintenance.js rename to frontend/components/ScheduledMaintenance.jsx diff --git a/frontend/components/TotalCoins.js b/frontend/components/TotalCoins.jsx similarity index 83% rename from frontend/components/TotalCoins.js rename to frontend/components/TotalCoins.jsx index f3771000..5ded3331 100644 --- a/frontend/components/TotalCoins.js +++ b/frontend/components/TotalCoins.jsx @@ -1,6 +1,7 @@ import React from "react"; -import AmountWidget from "./AmountWidget"; -import { totalSupply } from "../../common/lumens.js"; +import AmountWidget from "./AmountWidget.jsx"; +import * as lumens from "../../common/lumens.js"; +const { totalSupply } = lumens; export default class TotalCoins extends AmountWidget { constructor(props) { diff --git a/frontend/components/TransactionsChart.js b/frontend/components/TransactionsChart.js deleted file mode 100644 index 1b747108..00000000 --- a/frontend/components/TransactionsChart.js +++ /dev/null @@ -1,115 +0,0 @@ -import React from "react"; -import Panel from "muicss/lib/react/panel"; -import axios from "axios"; -import { scale, format } from "d3"; -import BarChart from "react-d3-components/lib/BarChart"; -import clone from "lodash/clone"; -import each from "lodash/each"; - -export default class TransactionsChart extends React.Component { - constructor(props) { - super(props); - this.panel = null; - this.colorScale = scale.category10(); - this.state = { - loading: true, - chartWidth: 400, - chartHeigth: this.props.chartHeigth || 120, - }; - this.url = `${this.props.horizonURL}/ledgers?order=desc&limit=${this.props.limit}`; - } - - componentDidMount() { - this.getLedgers(); - // Update chart width - this.updateSize(); - setInterval(() => this.updateSize(), 5000); - } - - updateSize() { - let value = this.panel.offsetWidth - 20; - if (this.state.chartWidth != value) { - this.setState({ chartWidth: value }); - } - } - - onNewLedger(ledger) { - let data = clone(this.state.data); - data[0].values.push({ - x: ledger.sequence.toString(), - y: ledger.successful_transaction_count, - }); - data[1].values.push({ - x: ledger.sequence.toString(), - y: ledger.operation_count - ledger.successful_transaction_count, - }); - data[0].values.shift(); - data[1].values.shift(); - this.setState({ loading: false, data }); - } - - getLedgers() { - axios.get(this.url).then((response) => { - let data = [ - { - label: "Transactions", - values: [], - }, - { - label: "Operations", - values: [], - }, - ]; - each(response.data._embedded.records, (ledger) => { - data[0].values.unshift({ - x: ledger.sequence.toString(), - y: ledger.successful_transaction_count, - }); - data[1].values.unshift({ - x: ledger.sequence.toString(), - y: ledger.operation_count - ledger.successful_transaction_count, - }); - }); - this.setState({ loading: false, data }); - // Start listening to events - this.props.emitter.addListener( - this.props.newLedgerEventName, - this.onNewLedger.bind(this), - ); - }); - } - - render() { - return ( -
{ - this.panel = el; - }} - > - -
- Successful{" "} - Txs &{" "} - Ops in - the last {this.props.limit} ledgers: {this.props.network} - - API - -
- {this.state.loading ? ( - "Loading..." - ) : ( - - )} -
-
- ); - } -} diff --git a/frontend/components/TransactionsChart.jsx b/frontend/components/TransactionsChart.jsx new file mode 100644 index 00000000..e6a2b3a8 --- /dev/null +++ b/frontend/components/TransactionsChart.jsx @@ -0,0 +1,195 @@ +import React from "react"; +import Panel from "muicss/lib/react/panel"; +import axios from "axios"; +import * as d3 from "d3"; +import D3BarChartNoXLabels from "./D3BarChartNoXLabels.jsx"; +import clone from "lodash/clone"; +import each from "lodash/each"; + +export default class TransactionsChart extends React.Component { + constructor(props) { + super(props); + this.panel = null; + // Use the same colors as the original react-d3-components + this.colorScale = d3 + .scaleOrdinal() + .range([ + "#1f77b4", + "#ff7f0e", + "#2ca02c", + "#d62728", + "#9467bd", + "#8c564b", + "#e377c2", + "#7f7f7f", + "#bcbd22", + "#17becf", + ]); + this.state = { + loading: true, + chartWidth: 400, + chartHeight: this.props.chartHeight || 120, + yAxisMax: 450, // Default value, will be updated dynamically + yAxisStep: 50, // Default value, will be updated dynamically + }; + this.url = `${this.props.horizonURL}/ledgers?order=desc&limit=${this.props.limit}`; + } + + componentDidMount() { + this.getLedgers(); + // Update chart width + this.updateSize(); + setInterval(() => this.updateSize(), 5000); + } + + updateSize() { + let value = this.panel.offsetWidth - 20; + if (this.state.chartWidth != value) { + this.setState({ chartWidth: value }); + } + } + + calculateDynamicYAxisParams(data) { + // For stacked charts, we need to find the maximum combined value + let maxValue = 0; + + if (data.length === 2) { + // For stacked data, calculate the sum of both series at each point + const xValues = data[0].values.map((d) => d.x); + + xValues.forEach((x, index) => { + const bottomValue = data[0].values[index].y; + const topValue = data[1].values[index].y; + const combinedValue = bottomValue + topValue; + + if (combinedValue > maxValue) { + maxValue = combinedValue; + } + }); + } else { + // Fallback for non-stacked charts + data.forEach((series) => { + series.values.forEach((point) => { + if (point.y > maxValue) { + maxValue = point.y; + } + }); + }); + } + + // Determine step size based on network type + let stepSize; + if (this.props.network === "Test network") { + stepSize = 1; // Test network uses step size of 1 + } else { + // Live network: choose between 50 and 100 based on resulting tick count + const ticksWith50 = Math.ceil(maxValue / 50); + stepSize = ticksWith50 <= 10 ? 50 : 100; + } + + const yAxisMax = Math.ceil(maxValue / stepSize) * stepSize; + + // Ensure minimum values for better chart readability + let minYAxisMax; + if (this.props.network === "Test network") { + minYAxisMax = 10; // Smaller minimum for test network + } else { + minYAxisMax = stepSize === 50 ? 100 : 200; + } + + return { + yAxisMax: Math.max(yAxisMax, minYAxisMax), + yAxisStep: stepSize, + }; + } + + onNewLedger(ledger) { + let data = clone(this.state.data); + data[0].values.push({ + x: ledger.sequence.toString(), + y: ledger.successful_transaction_count, + }); + data[1].values.push({ + x: ledger.sequence.toString(), + y: ledger.operation_count - ledger.successful_transaction_count, + }); + data[0].values.shift(); + data[1].values.shift(); + + // Calculate dynamic yAxisMax and yAxisStep based on data + const { yAxisMax, yAxisStep } = this.calculateDynamicYAxisParams(data); + + this.setState({ loading: false, data, yAxisMax, yAxisStep }); + } + + getLedgers() { + axios.get(this.url).then((response) => { + let data = [ + { + label: "Transactions", + values: [], + }, + { + label: "Operations", + values: [], + }, + ]; + each(response.data._embedded.records, (ledger) => { + data[0].values.unshift({ + x: ledger.sequence.toString(), + y: ledger.successful_transaction_count, + }); + data[1].values.unshift({ + x: ledger.sequence.toString(), + y: ledger.operation_count - ledger.successful_transaction_count, + }); + }); + + // Calculate dynamic yAxisMax and yAxisStep based on data + const { yAxisMax, yAxisStep } = this.calculateDynamicYAxisParams(data); + + this.setState({ loading: false, data, yAxisMax, yAxisStep }); + // Start listening to events + this.props.emitter.addListener( + this.props.newLedgerEventName, + this.onNewLedger.bind(this), + ); + }); + } + + render() { + return ( +
{ + this.panel = el; + }} + > + +
+ Successful{" "} + Txs &{" "} + Ops in + the last {this.props.limit} ledgers: {this.props.network} + + API + +
+ {this.state.loading ? ( + "Loading..." + ) : ( + + )} +
+
+ ); + } +} diff --git a/frontend/main.jsx b/frontend/main.jsx new file mode 100644 index 00000000..0f971c0e --- /dev/null +++ b/frontend/main.jsx @@ -0,0 +1,10 @@ +import React from "react"; +import { createRoot } from "react-dom/client"; +import App from "./components/App.jsx"; + +// Add SCSS import +import "./scss/index.scss"; + +const container = document.getElementById("app"); +const root = createRoot(container); +root.render(); diff --git a/frontend/scss/index.scss b/frontend/scss/index.scss index 8d2ac09f..3619c6bc 100644 --- a/frontend/scss/index.scss +++ b/frontend/scss/index.scss @@ -1,3 +1,3 @@ -@import "../../node_modules/bourbon/app/assets/stylesheets/_bourbon"; +@import "bourbon"; @import "_main"; @import "_force"; diff --git a/gulpfile.babel.js b/gulpfile.babel.js deleted file mode 100644 index 44b4f8dc..00000000 --- a/gulpfile.babel.js +++ /dev/null @@ -1,132 +0,0 @@ -"use strict"; - -var _ = require("lodash"); -var bs = require("browser-sync").create(); -var gulp = require("gulp"); -var path = require("path"); -var webpack = require("webpack"); -var ExtractTextPlugin = require("extract-text-webpack-plugin"); - -var webpackOptions = { - entry: { - app: "./frontend/app.js", - vendor: [ - "react", - "react-dom", - "muicss", - "stellar-sdk", - "axios", - "d3", - "fbemitter", - ], - }, - devtool: "source-map", - resolve: { - root: [path.resolve("frontend"), path.resolve("common")], - modulesDirectories: ["node_modules"], - }, - module: { - loaders: [ - { - test: /\.js$/, - exclude: /node_modules/, - loader: "babel-loader", - query: { presets: ["es2015", "react"] }, - }, - { test: /\.json$/, loader: "json-loader" }, - { test: /\.html$/, loader: "file?name=[name].html" }, - { - test: /\.scss$/, - loader: ExtractTextPlugin.extract( - "style-loader", - "css-loader!sass-loader", - ), - }, - ], - }, - plugins: [ - new webpack.IgnorePlugin(/ed25519/), - new ExtractTextPlugin("style.css"), - ], -}; - -const develop = function (done) { - var options = merge(webpackOptions, { - output: { - filename: "[name].js", - path: "./.tmp", - }, - plugins: [new webpack.optimize.CommonsChunkPlugin("vendor", "vendor.js")], - }); - - var watchOptions = { - aggregateTimeout: 300, - }; - - var bsInitialized = false; - - var compiler = webpack(options); - compiler.purgeInputFileSystem(); - compiler.watch(watchOptions, function (error, stats) { - if (!bsInitialized) { - gulp.watch(".tmp/**/*").on("change", bs.reload); - bs.init({ - port: 3000, - online: false, - notify: false, - server: "./.tmp", - socket: { - domain: "localhost:3000", - }, - }); - bsInitialized = true; - } - console.log( - stats.toString({ - hash: false, - version: false, - timings: true, - chunks: false, - colors: true, - }), - ); - }); -}; - -const build = function (done) { - var options = merge(webpackOptions, { - bail: true, - output: { - // TODO chunkhash - filename: "[name].js", //"[name]-[chunkhash].js", - path: "./dist", - }, - plugins: [ - new webpack.optimize.CommonsChunkPlugin("vendor", "vendor.js"), - new webpack.optimize.DedupePlugin(), - new webpack.optimize.OccurenceOrderPlugin(), - new webpack.DefinePlugin({ - "process.env": { - NODE_ENV: JSON.stringify("production"), - }, - }), - new webpack.optimize.UglifyJsPlugin(), - ], - }); - - var compiler = webpack(options); - compiler.purgeInputFileSystem(); - compiler.run(done); -}; - -function merge(object1, object2) { - return _.mergeWith(object1, object2, function (a, b) { - if (_.isArray(a)) { - return a.concat(b); - } - }); -} - -gulp.task("develop", develop); -gulp.task("build", build); -gulp.task("default", develop); diff --git a/frontend/index.html b/index.html similarity index 92% rename from frontend/index.html rename to index.html index 5cecba75..0036dd93 100644 --- a/frontend/index.html +++ b/index.html @@ -71,20 +71,18 @@ type="text/css" media="screen" /> -
- - +