diff --git a/frontend/assets/favicon.svg b/frontend/assets/favicon.svg new file mode 100644 index 00000000..13a5ae2c --- /dev/null +++ b/frontend/assets/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/frontend/assets/stellar-logo-white.svg b/frontend/assets/stellar-logo-white.svg new file mode 100644 index 00000000..61c55f14 --- /dev/null +++ b/frontend/assets/stellar-logo-white.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/frontend/components/AccountBadge.jsx b/frontend/components/AccountBadge.jsx index 1aeda475..ddb77aad 100644 --- a/frontend/components/AccountBadge.jsx +++ b/frontend/components/AccountBadge.jsx @@ -4,31 +4,40 @@ import { knownAccounts } from "../common/known_accounts"; export default class AccountBadge extends React.Component { render() { + const known = + this.props.id != this.props.known ? knownAccounts[this.props.id] : null; + const accountUrl = this.props.horizonURL + "/accounts/" + this.props.id; + + // Known entities read by name; the raw key stays one hover away. + if (known) { + const name = isObject(known) ? known.name : known; + return ( + + + {name} + + + ); + } + return ( {this.props.id.substr(0, 4)} - {knownAccounts[this.props.id] && this.props.id != this.props.known ? ( - isObject(knownAccounts[this.props.id]) ? ( - - - - ) : ( - {knownAccounts[this.props.id]} - ) - ) : ( - "" - )} ); } diff --git a/frontend/components/AccountBalance.jsx b/frontend/components/AccountBalance.jsx index 9cc57492..4b7793af 100644 --- a/frontend/components/AccountBalance.jsx +++ b/frontend/components/AccountBalance.jsx @@ -1,6 +1,5 @@ import React from "react"; import AmountWidget from "./AmountWidget.jsx"; -import Panel from "muicss/lib/react/panel"; import axios from "axios"; import find from "lodash/find"; @@ -31,16 +30,11 @@ export default class AccountBalance extends AmountWidget { }); } - renderName() { - return ( - - {this.props.name}:{" "} - - - {this.props.id.substr(0, 4)} - - - - ); + name() { + return `${this.props.name} balance`; + } + + apiUrl() { + return this.url; } } diff --git a/frontend/components/AmountWidget.jsx b/frontend/components/AmountWidget.jsx index 9c3a8391..93e2232d 100644 --- a/frontend/components/AmountWidget.jsx +++ b/frontend/components/AmountWidget.jsx @@ -1,6 +1,6 @@ import React from "react"; -import Panel from "muicss/lib/react/panel"; import BigNumber from "bignumber.js"; +import Card from "./ui/Card.jsx"; export default class AmountWidget extends React.Component { constructor(props) { @@ -8,16 +8,19 @@ export default class AmountWidget extends React.Component { this.state = { loading: true }; } - renderName() { + // Subclasses override these. + name() { + return null; + } + + apiUrl() { return null; } render() { let amountBig; let amount; - if (this.state.loading) { - amountBig = "Loading..."; - } else { + if (!this.state.loading) { if (this.state.amount >= 1000000000) { amountBig = Math.floor(this.state.amount / 10000000) / 100 + "B"; } else if (this.state.amount >= 1000000) { @@ -28,27 +31,32 @@ export default class AmountWidget extends React.Component { amountBig = Math.floor(this.state.amount); } - if (this.state.code) { - amountBig += ` ${this.state.code}`; - } - amount = new BigNumber(this.state.amount).toFormat(7); } return ( - -
{this.renderName()}
-
{amountBig}
-
- {this.state.loading ? ( - "" - ) : ( - + + {this.state.loading ? ( +
+
+ +
+
+ +
+
+ ) : ( +
+
+ {amountBig} + {this.state.code} +
+
{amount} {this.state.code} - - )} -
- +
+
+ )} + ); } } diff --git a/frontend/components/App.jsx b/frontend/components/App.jsx index 689f70ed..0713875d 100644 --- a/frontend/components/App.jsx +++ b/frontend/components/App.jsx @@ -1,5 +1,4 @@ import React from "react"; -import Panel from "muicss/lib/react/panel"; import { EventEmitter } from "fbemitter"; import axios from "axios"; import moment from "moment"; @@ -19,6 +18,7 @@ import LumensCirculating from "./LumensCirculating.jsx"; import LumensNonCirculating from "./LumensNonCirculating.jsx"; import RecentOperations from "./RecentOperations.jsx"; import TotalCoins from "./TotalCoins.jsx"; +import SupplyDistribution from "./SupplyDistribution.jsx"; import { LIVE_NEW_LEDGER, TEST_NEW_LEDGER } from "../events"; import { setTimeOffset } from "../common/time"; import { ScheduledMaintenance } from "./ScheduledMaintenance.jsx"; @@ -27,6 +27,42 @@ import sanitizeHtml from "../utilities/sanitizeHtml.js"; const horizonLive = "https://horizon.stellar.org"; const horizonTest = "https://horizon-testnet.stellar.org"; +// Route → network. "/" shows mainnet; "/testnet" shows the test network. +// The production server already falls back to index.html for any GET. +const NETWORKS = { + live: { + label: "Mainnet", + horizonURL: horizonLive, + newLedgerEventName: LIVE_NEW_LEDGER, + path: "/", + title: "Stellar Network Dashboard", + }, + test: { + label: "Testnet", + horizonURL: horizonTest, + newLedgerEventName: TEST_NEW_LEDGER, + path: "/testnet", + title: "Stellar Network Dashboard · Testnet", + }, +}; + +function networkFromPath(pathname) { + return pathname.indexOf("/testnet") === 0 ? "test" : "live"; +} + +// "/live" and "/testnet/live" show the fullscreen big-screen view. +function liveViewFromPath(pathname) { + return /(^|\/)live\/?$/.test(pathname); +} + +function pathFor(network, liveView) { + const base = NETWORKS[network].path; + if (!liveView) { + return base; + } + return base === "/" ? "/live" : `${base}/live`; +} + export default class App extends React.Component { constructor(props) { super(props); @@ -82,12 +118,36 @@ export default class App extends React.Component { may4 = true; } - this.state = { forceTheme, may4 }; + this.startedStreams = {}; + this.state = { + forceTheme, + may4, + network: networkFromPath(window.location.pathname), + liveView: liveViewFromPath(window.location.pathname), + }; + } + + // Self-hosted pixel font for the May 4th theme, fetched only when the + // theme is on so normal visits don't download it. + loadForceThemeFont() { + if (this.state.forceTheme) { + import("@fontsource/press-start-2p"); + } } componentDidMount() { - this.streamLedgers(horizonLive, LIVE_NEW_LEDGER); - this.streamLedgers(horizonTest, TEST_NEW_LEDGER); + this.ensureStream(this.state.network); + document.title = NETWORKS[this.state.network].title; + this.loadForceThemeFont(); + + this.onPopState = () => { + const network = networkFromPath(window.location.pathname); + const liveView = liveViewFromPath(window.location.pathname); + this.ensureStream(network); + document.title = NETWORKS[network].title; + this.setState({ network, liveView }); + }; + window.addEventListener("popstate", this.onPopState); this.getStatusPageData(); this.statusPageUpdateInterval = setInterval( @@ -98,6 +158,37 @@ export default class App extends React.Component { componentWillUnmount() { clearInterval(this.statusPageUpdateInterval); + window.removeEventListener("popstate", this.onPopState); + } + + // Open the Horizon ledger stream for a network once, on first visit. + ensureStream(network) { + if (!this.startedStreams[network]) { + this.startedStreams[network] = true; + this.streamLedgers( + NETWORKS[network].horizonURL, + NETWORKS[network].newLedgerEventName, + ); + } + } + + switchNetwork(network) { + if (network === this.state.network) { + return; + } + window.history.pushState({}, "", NETWORKS[network].path); + this.ensureStream(network); + document.title = NETWORKS[network].title; + this.setState({ network, liveView: false }); + } + + setLiveView(liveView) { + window.history.pushState( + {}, + "", + pathFor(this.state.network, liveView), + ); + this.setState({ liveView }); } getStatusPageData() { @@ -160,216 +251,290 @@ export default class App extends React.Component { }); } - turnOffForceTheme() { + turnOffForceTheme(e) { + // Returning false doesn't prevent the anchor's default in React, so the + // page would jump to "#". Prevent it explicitly. + if (e) { + e.preventDefault(); + } this.setState({ forceTheme: false }); - return false; } - render() { + renderLiveMain() { + const net = NETWORKS.live; return ( -
- + this.setLiveView(true)} /> - - { - /* Incidents */ - this.state.statusPage - ? this.state.statusPage.incidents.map((m) => { - return ( - -
- - {m.name} - {" "} - (started: {moment(m.started_at).fromNow()} - {m.incident_updates.length > 0 - ? ", last update: " + - moment(m.incident_updates[0].created_at).fromNow() - : null} - )
- - Affected: {m.components.map((c) => c.name).join(", ")} - -
- {m.incident_updates.length > 0 ? ( - {sanitizeHtml(m.incident_updates[0].body)} - ) : null} -
-
- ); - }) - : null - } - { - /* Scheduled maintenances */ - this.state.statusPage && - this.state.statusPage.scheduled_maintenances.length ? ( - - ) : null - } - {this.chrome57 ? ( - -
- You are using Chrome 57. There is a{" "} - - known bug - {" "} - that makes the Dashboard app consume extensive amounts of memory. - Please switch to any other browser or wait for a fix by a Chromium - team. -
-
- ) : null} - {this.state.sleeping ? ( - -
- System sleep detected. Waiting for internet connection... -
-
- ) : null} - {this.state.forceTheme && this.state.may4 ? ( -

- May the 4th be with you! -

- ) : null} -
-
-

Live network status

-
-
- +
+
+

Network activity

+
+
- +
-
+
- +
-
-

LUMEN SUPPLY

-
- -
- -
- -
- -
- +
+

Lumen supply

+
+
+ +
+
+ +
+
+ +
+
+ +
-

+

+ How these numbers are calculated:{" "} - Lumen Supply Metrics + Lumen supply metrics -

+

-
-

Network Nodes

-

- View network nodes on Stellarbeat and visualize consensus. -
- - Explore Nodes - -

+
+

Network nodes

+
+
+ + View network nodes on OBSRVR Radar and visualize consensus. + + + Explore nodes → + +
+
+
+ + ); + } -
-

Test network status

-
- - + renderTestMain() { + const net = NETWORKS.test; + return ( +
+ this.setLiveView(true)} + /> +
+
+

Network activity

+
+
+ + +
+
+ + + +
-
- - - +
+
+ ); + } + + render() { + if (this.state.liveView) { + const net = NETWORKS[this.state.network]; + return ( +
+ this.setLiveView(false)} + /> +
+ ); + } + + return ( +
+ + +
+ { + /* Incidents */ + this.state.statusPage + ? this.state.statusPage.incidents.map((m) => { + return ( +
+ + {m.name} + +
+ started {moment(m.started_at).fromNow()} + {m.incident_updates.length > 0 + ? ", last update " + + moment(m.incident_updates[0].created_at).fromNow() + : null} + {" · affected: "} + {m.components.map((c) => c.name).join(", ")} +
+ {m.incident_updates.length > 0 ? ( +
{sanitizeHtml(m.incident_updates[0].body)}
+ ) : null} +
+ ); + }) + : null + } + { + /* Scheduled maintenances */ + this.state.statusPage && + this.state.statusPage.scheduled_maintenances.length ? ( + + ) : null + } + {this.chrome57 ? ( +
+ You are using Chrome 57. There is a{" "} + + known bug + {" "} + that makes the Dashboard app consume extensive amounts of memory. + Please switch to any other browser or wait for a fix by a Chromium + team.
-
- + ) : null} + {this.state.sleeping ? ( +
+ System sleep detected. Waiting for internet connection...
-
+ ) : null} + {this.state.forceTheme && this.state.may4 ? ( +

+ May the 4th be with you! +

+ ) : null}
+ + {this.state.network === "live" + ? this.renderLiveMain() + : this.renderTestMain()} + +
+
+ Live metrics for the Stellar network, streamed from Horizon and the + dashboard API.{" "} + + Source on GitHub + +
+
); } diff --git a/frontend/components/AppBar.jsx b/frontend/components/AppBar.jsx index 3d4cc611..88a93b72 100644 --- a/frontend/components/AppBar.jsx +++ b/frontend/components/AppBar.jsx @@ -1,47 +1,120 @@ import React from "react"; +import logoUrl from "../assets/stellar-logo-white.svg"; + +const SunIcon = () => ( + + + + +); + +const MoonIcon = () => ( + + + +); export default class AppBar extends React.Component { + constructor(props) { + super(props); + this.state = { + theme: document.documentElement.getAttribute("data-theme") || "dark", + }; + } + + toggleTheme() { + const theme = this.state.theme === "dark" ? "light" : "dark"; + document.documentElement.setAttribute("data-theme", theme); + try { + localStorage.setItem("theme", theme); + } catch (e) { + // Storage can be unavailable (private browsing); the toggle still works. + } + // Charts listen for this to re-resolve their palette. + window.dispatchEvent(new Event("themechange")); + this.setState({ theme }); + } + render() { return ( -
- + ); } } diff --git a/frontend/components/D3BarChart.jsx b/frontend/components/D3BarChart.jsx index 56fdbe22..b753b1b8 100644 --- a/frontend/components/D3BarChart.jsx +++ b/frontend/components/D3BarChart.jsx @@ -1,150 +1,151 @@ -import React, { useEffect, useRef } from "react"; +import React, { useEffect, useRef, useState } from "react"; import * as d3 from "d3"; - +import { + getChartTheme, + roundedTopRect, + createTooltip, + tooltipRow, +} from "./ui/chartUtils.js"; + +// Single-series bar chart (used for ledger close times). export default function D3BarChart({ data, width = 400, height = 120, - margin = { top: 10, right: 10, bottom: 30, left: 50 }, - colorScale, + margin = { top: 10, right: 10, bottom: 8, left: 50 }, tickFormat, + tooltipTitle, + valueFormat, }) { const svgRef = useRef(); + // Charts re-render when the theme changes so D3 picks up the new palette. + const [themeTick, setThemeTick] = useState(0); + + useEffect(() => { + const onThemeChange = () => setThemeTick((t) => t + 1); + window.addEventListener("themechange", onThemeChange); + return () => window.removeEventListener("themechange", onThemeChange); + }, []); useEffect(() => { if (!data || data.length === 0) return; + const theme = getChartTheme(svgRef.current); 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); + const values = data[0].values; + const xValues = values.map((d) => d.x); + const yValues = values.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 + .padding(1.0); 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 barWidth = 5; + 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 + // Recessive horizontal gridlines carry the scale; no axis spines. + const yTickValues = yScale.ticks(4); + g.append("g") + .attr("class", "chart-grid") .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 + .data(yTickValues) + .enter() + .append("line") + .attr("x1", 0) + .attr("x2", innerWidth) + .attr("y1", (d) => yScale(d)) + .attr("y2", (d) => yScale(d)) + .style("stroke", theme.grid); + + // Bars: thin marks with rounded data-ends anchored to the baseline. + g.selectAll(".bar") + .data(values) + .enter() + .append("path") + .attr("class", "bar") + .attr("d", (d) => + roundedTopRect( + xScale(d.x) - barWidth / 2, + yScale(d.y), + barWidth, + innerHeight - yScale(d.y), + 2, + ), + ) + .attr("fill", theme.primary); + + // Y axis: text only. 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) - } + .tickSize(0) + .tickPadding(8) + .tickValues(yTickValues) + .tickFormat(tickFormat || d3.format("d")); 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 ; + yAxisGroup.select(".domain").remove(); + yAxisGroup.selectAll("text").style("fill", theme.axisText); + + // Hover layer: full-height hit targets, one per bar. + const tooltip = createTooltip(); + const step = Math.max(innerWidth / Math.max(xValues.length, 1), barWidth); + g.selectAll(".hit") + .data(values) + .enter() + .append("rect") + .attr("class", "hit") + .attr("x", (d) => xScale(d.x) - step / 2) + .attr("y", 0) + .attr("width", step) + .attr("height", innerHeight) + .attr("fill", "transparent") + .on("mousemove", (event, d) => { + const title = tooltipTitle ? tooltipTitle(d.x) : d.x; + const value = valueFormat ? valueFormat(d.y) : d.y; + tooltip.show( + `
${title}
` + + tooltipRow(theme.primary, data[0].label, value), + event.clientX, + event.clientY, + ); + }) + .on("mouseleave", () => tooltip.hide()); + + return () => tooltip.destroy(); + }, [ + data, + width, + height, + margin, + tickFormat, + tooltipTitle, + valueFormat, + themeTick, + ]); + + return ( + + ); } diff --git a/frontend/components/D3BarChartNoXLabels.jsx b/frontend/components/D3BarChartNoXLabels.jsx index 48a65f13..6742b67d 100644 --- a/frontend/components/D3BarChartNoXLabels.jsx +++ b/frontend/components/D3BarChartNoXLabels.jsx @@ -1,207 +1,238 @@ -import React, { useEffect, useRef } from "react"; +import React, { useEffect, useRef, useState } from "react"; import * as d3 from "d3"; - +import { + getChartTheme, + roundedTopRect, + createTooltip, + tooltipRow, +} from "./ui/chartUtils.js"; + +// Stacked two-series bar chart without x labels (txs/ops, successful/failed). 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, + margin = { top: 10, right: 10, bottom: 8, left: 50 }, tickFormat, - yAxisMax = 450, // Maximum Y value - yAxisStep = 50, // Y axis increment step + yAxisMax = 450, + yAxisStep = 50, + tooltipTitle, + valueFormat, + xLabelEvery = 0, // 0 = no x labels; N = label every Nth point }) { const svgRef = useRef(); + // Charts re-render when the theme changes so D3 picks up the new palette. + const [themeTick, setThemeTick] = useState(0); + + useEffect(() => { + const onThemeChange = () => setThemeTick((t) => t + 1); + window.addEventListener("themechange", onThemeChange); + return () => window.removeEventListener("themechange", onThemeChange); + }, []); useEffect(() => { if (!data || data.length === 0) return; + const theme = getChartTheme(svgRef.current); + const seriesColors = [theme.seriesA, theme.seriesB]; 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); + const xValues = data[0].values.map((d) => d.x); - // 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 + .padding(1.0); const yScale = d3 .scaleLinear() - .domain([0, yAxisMax]) // Use fixed max instead of data max + .domain([0, yAxisMax]) .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 barWidth = 5; + const segmentGap = 2; // surface gap between stacked segments + 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 + // Recessive horizontal gridlines carry the scale; no axis spines. + // Let D3 pick round tick values within the fixed domain. + const yTickValues = yScale.ticks(4); + g.append("g") + .attr("class", "chart-grid") + .selectAll("line") + .data(yTickValues) + .enter() + .append("line") + .attr("x1", 0) + .attr("x2", innerWidth) + .attr("y1", (d) => yScale(d)) + .attr("y2", (d) => yScale(d)) + .style("stroke", theme.grid); + + // Stack the two series (bottom = series 0, top = series 1). 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, + bottom: data[0].values[index].y, + top: data[1].values[index].y, }); }); - // 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") + const bars = g + .selectAll(".bar-group") .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"); + .append("g") + .attr("class", "bar-group"); + + // Bottom segment: flat unless it is the data end, rounded when alone. + bars + .append("path") + .attr("d", (d) => { + const x = xScale(d.x) - barWidth / 2; + const yTop = yScale(d.bottom); + const h = innerHeight - yTop; + if (d.top > 0) { + return h > 0 + ? `M${x},${yTop} H${x + barWidth} V${innerHeight} H${x} Z` + : ""; + } + return roundedTopRect(x, yTop, barWidth, h, 2); + }) + .attr("fill", seriesColors[0]); + + // Top segment: rounded data end, separated by a surface gap. + bars + .append("path") + .attr("d", (d) => { + if (d.top <= 0) { + return ""; + } + const x = xScale(d.x) - barWidth / 2; + const yTotal = yScale(d.bottom + d.top); + const yBottom = yScale(d.bottom); + const h = Math.max(yBottom - yTotal - segmentGap, 0.5); + return roundedTopRect(x, yTotal, barWidth, h, 2); + }) + .attr("fill", seriesColors[1]); } else { - // Fallback to original behavior for non-stacked charts + // Fallback for non-stacked charts. data.forEach((series, seriesIndex) => { g.selectAll(`.bar-${seriesIndex}`) .data(series.values) .enter() - .append("rect") + .append("path") .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"); + .attr("d", (d) => + roundedTopRect( + xScale(d.x) - barWidth / 2, + yScale(d.y), + barWidth, + innerHeight - yScale(d.y), + 2, + ), + ) + .attr("fill", seriesColors[seriesIndex % seriesColors.length]); }); } - // 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] - + // Y axis: text only. 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 - } + .tickSize(0) + .tickPadding(8) + .tickValues(yTickValues) + .tickFormat(tickFormat || d3.format("d")); const yAxisGroup = g.append("g").attr("class", "axis").call(yAxis); + yAxisGroup.select(".domain").remove(); + yAxisGroup.selectAll("text").style("fill", theme.axisText); + + // Optional sparse x labels (e.g. dates on the 30-day chart). + if (xLabelEvery > 0) { + const xTickValues = xValues.filter((_, i) => i % xLabelEvery === 0); + const xAxis = d3 + .axisBottom(xScale) + .tickSize(0) + .tickPadding(8) + .tickValues(xTickValues); + + const xAxisGroup = g + .append("g") + .attr("class", "axis") + .attr("transform", `translate(0,${innerHeight})`) + .call(xAxis); + xAxisGroup.select(".domain").remove(); + xAxisGroup.selectAll("text").style("fill", theme.axisText); + } - // 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"); + // Hover layer: full-height hit targets, one per bar. + const tooltip = createTooltip(); + if (data.length === 2) { + const step = Math.max( + innerWidth / Math.max(xValues.length, 1), + barWidth, + ); + g.selectAll(".hit") + .data(stackedData) + .enter() + .append("rect") + .attr("class", "hit") + .attr("x", (d) => xScale(d.x) - step / 2) + .attr("y", 0) + .attr("width", step) + .attr("height", innerHeight) + .attr("fill", "transparent") + .on("mousemove", (event, d) => { + const title = tooltipTitle ? tooltipTitle(d.x) : d.x; + const fmt = valueFormat || ((v) => v.toLocaleString("en-US")); + tooltip.show( + `
${title}
` + + tooltipRow(seriesColors[0], data[0].label, fmt(d.bottom)) + + tooltipRow(seriesColors[1], data[1].label, fmt(d.top)), + event.clientX, + event.clientY, + ); + }) + .on("mouseleave", () => tooltip.hide()); + } - yAxisGroup - .select(".domain") - .style("stroke", "#000") - .style("shape-rendering", "crispEdges"); + return () => tooltip.destroy(); }, [ data, width, height, margin, - colorScale, tickFormat, yAxisMax, yAxisStep, + tooltipTitle, + valueFormat, + xLabelEvery, + themeTick, ]); - return ; + return ( + s.label).join(" and ")} bar chart` + : "chart" + } + > + ); } diff --git a/frontend/components/FailedTransactionsChart.jsx b/frontend/components/FailedTransactionsChart.jsx index 34fc5dbc..a61aa5a5 100644 --- a/frontend/components/FailedTransactionsChart.jsx +++ b/frontend/components/FailedTransactionsChart.jsx @@ -1,30 +1,15 @@ 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"; +import Card from "./ui/Card.jsx"; 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, @@ -33,17 +18,25 @@ export default class FailedTransactionsChart extends React.Component { yAxisStep: 100, // Default value, will be updated dynamically }; this.url = `${this.props.horizonURL}/ledgers?order=desc&limit=${this.props.limit}`; + this.tooltipTitle = (x) => `Ledger #${x}`; } componentDidMount() { this.getLedgers(); // Update chart width this.updateSize(); - setInterval(() => this.updateSize(), 5000); + this.sizeInterval = setInterval(() => this.updateSize(), 5000); + } + + componentWillUnmount() { + clearInterval(this.sizeInterval); + if (this.newLedgerListener) { + this.newLedgerListener.remove(); + } } updateSize() { - let value = this.panel.offsetWidth - 20; + let value = this.panel.offsetWidth - 42; if (this.state.chartWidth != value) { this.setState({ chartWidth: value }); } @@ -79,7 +72,7 @@ export default class FailedTransactionsChart extends React.Component { // Determine step size based on network type let stepSize; - if (this.props.network === "Test network") { + if (this.props.network === "Testnet") { stepSize = 1; // Test network uses step size of 1 } else { // Live network: choose between 50 and 100 based on resulting tick count @@ -91,7 +84,7 @@ export default class FailedTransactionsChart extends React.Component { // Ensure minimum values for better chart readability let minYAxisMax; - if (this.props.network === "Test network") { + if (this.props.network === "Testnet") { minYAxisMax = 10; // Smaller minimum for test network } else { minYAxisMax = stepSize === 50 ? 100 : 200; @@ -126,11 +119,11 @@ export default class FailedTransactionsChart extends React.Component { axios.get(this.url).then((response) => { let data = [ { - label: "Success", + label: "Successful", values: [], }, { - label: "Fail", + label: "Failed", values: [], }, ]; @@ -150,7 +143,7 @@ export default class FailedTransactionsChart extends React.Component { this.setState({ loading: false, data, yAxisMax, yAxisStep }); // Start listening to events - this.props.emitter.addListener( + this.newLedgerListener = this.props.emitter.addListener( this.props.newLedgerEventName, this.onNewLedger.bind(this), ); @@ -164,33 +157,38 @@ export default class FailedTransactionsChart extends React.Component { this.panel = el; }} > - -
- - Successful - {" "} - &{" "} - Failed{" "} - Txs in the last {this.props.limit} ledgers: {this.props.network} - - API - -
+ + + Successful + + + Failed + +
+ } + > {this.state.loading ? ( - "Loading..." +
) : ( )} - +
); } diff --git a/frontend/components/FeeStats.jsx b/frontend/components/FeeStats.jsx index cb3302bc..e4b82ba3 100644 --- a/frontend/components/FeeStats.jsx +++ b/frontend/components/FeeStats.jsx @@ -1,37 +1,29 @@ import React from "react"; -import Panel from "muicss/lib/react/panel"; import axios from "axios"; -import moment from "moment"; -import clone from "lodash/clone"; -import each from "lodash/each"; -import defaults from "lodash/defaults"; import get from "lodash/get"; -import AccountBadge from "./AccountBadge.jsx"; -import AssetLink from "./AssetLink.jsx"; -import BigNumber from "bignumber.js"; -import { ago } from "../common/time"; +import Card from "./ui/Card.jsx"; export default class FeeStats extends React.Component { constructor(props) { super(props); - this.state = { loading: true, stats: {} }; + this.state = { loading: true, expanded: false, stats: {} }; this.url = `${this.props.horizonURL}/fee_stats`; this.nameMap = [ - { id: "ledger_capacity_usage", name: "Capacity Usage" }, - { id: "max_fee.max", name: "Max Accepted Fee" }, - { id: "max_fee.min", name: "Min Accepted Fee" }, - { id: "max_fee.mode", name: "Mode Accepted Fee" }, - { id: "max_fee.p10", name: "10th Percentile Accepted Fee" }, - { id: "max_fee.p20", name: "20th Percentile Accepted Fee" }, - { id: "max_fee.p30", name: "30th Percentile Accepted Fee" }, - { id: "max_fee.p40", name: "40th Percentile Accepted Fee" }, - { id: "max_fee.p50", name: "50th Percentile Accepted Fee" }, - { id: "max_fee.p60", name: "60th Percentile Accepted Fee" }, - { id: "max_fee.p70", name: "70th Percentile Accepted Fee" }, - { id: "max_fee.p80", name: "80th Percentile Accepted Fee" }, - { id: "max_fee.p90", name: "90th Percentile Accepted Fee" }, - { id: "max_fee.p95", name: "95th Percentile Accepted Fee" }, - { id: "max_fee.p99", name: "99th Percentile Accepted Fee" }, + { id: "ledger_capacity_usage", name: "Capacity usage" }, + { id: "max_fee.max", name: "Max accepted fee" }, + { id: "max_fee.min", name: "Min accepted fee" }, + { id: "max_fee.mode", name: "Mode accepted fee" }, + { id: "max_fee.p10", name: "10th percentile accepted fee" }, + { id: "max_fee.p20", name: "20th percentile accepted fee" }, + { id: "max_fee.p30", name: "30th percentile accepted fee" }, + { id: "max_fee.p40", name: "40th percentile accepted fee" }, + { id: "max_fee.p50", name: "50th percentile accepted fee" }, + { id: "max_fee.p60", name: "60th percentile accepted fee" }, + { id: "max_fee.p70", name: "70th percentile accepted fee" }, + { id: "max_fee.p80", name: "80th percentile accepted fee" }, + { id: "max_fee.p90", name: "90th percentile accepted fee" }, + { id: "max_fee.p95", name: "95th percentile accepted fee" }, + { id: "max_fee.p99", name: "99th percentile accepted fee" }, ]; } @@ -56,73 +48,94 @@ export default class FeeStats extends React.Component { clearInterval(this.timerID); } - capacityStyle(cap) { - if (cap <= 0.5) { - return { color: "green" }; - } else if (cap > 0.5 && cap <= 0.7) { - return { color: "orange" }; - } else if (cap > 0.7 && cap <= 0.9) { - return { color: "red", fontWeight: "bold" }; - } else if (cap > 0.9) { - return { color: "brown", fontWeight: "bold" }; - } - } - - feeStyle(fee) { - if (fee <= 200) { - return { color: "green" }; - } else if (fee > 200 && fee <= 500) { - return { color: "orange" }; - } else if (fee > 500 && fee <= 1000) { - return { color: "red", fontWeight: "bold" }; - } else if (fee > 1000) { - return { color: "brown", fontWeight: "bold" }; + capacityClass(cap) { + if (cap <= 0.7) { + return ""; + } else if (cap <= 0.9) { + return "text-warning"; } + return "text-danger"; } render() { + const capacity = get(this.state.stats, "ledger_capacity_usage"); + return ( - -
- Fee stats (last 5 ledgers) - - API - -
- - - - - - - - - {this.state.loading ? ( - - - - ) : ( - this.nameMap.map((field) => { - let styleFn = this.feeStyle; - let val = get(this.state.stats, field.id); - let displayVal = get(this.state.stats, field.id); + + {this.state.loading ? ( +
+ + + +
+ ) : ( +
+
+
+ Capacity usage + + {Math.round(capacity * 100)}% + +
+
+
+
+
+ +
+
+
Min accepted
+
+ {get(this.state.stats, "max_fee.min")} +
+
+
+
Mode
+
+ {get(this.state.stats, "max_fee.mode")} +
+
+
+
Max accepted
+
+ {get(this.state.stats, "max_fee.max")} +
+
+
- if (field.id === "ledger_capacity_usage") { - styleFn = this.capacityStyle; - displayVal = `${Math.round(val * 100)}%`; - } + {this.state.expanded ? ( +
MetricValue
Loading...
+ + {this.nameMap + .filter((field) => field.id !== "ledger_capacity_usage") + .map((field) => ( + + + + + ))} + +
{field.name} + {get(this.state.stats, field.id)} +
+ ) : null} - return ( - - {field.name} - {displayVal} - - ); - }) - )} - - -
+ +
+ )} + ); } } diff --git a/frontend/components/Incidents.jsx b/frontend/components/Incidents.jsx index 164bcf91..7b2908f6 100644 --- a/frontend/components/Incidents.jsx +++ b/frontend/components/Incidents.jsx @@ -1,7 +1,7 @@ import React from "react"; -import Panel from "muicss/lib/react/panel"; import axios from "axios"; import moment from "moment"; +import Card from "./ui/Card.jsx"; export default class Incidents extends React.Component { constructor(props) { @@ -34,25 +34,34 @@ export default class Incidents extends React.Component { render() { return ( - -
Incidents
+ {this.state.loading ? ( - Loading... +
+ + + +
) : ( -
    +
      {this.state.incidents.map((m) => { return (
    • - + {m.name} - {" "} - ({moment(m.started_at).fromNow()}) + + + {moment(m.started_at).fromNow()} +
    • ); })}
    )} - + ); } } diff --git a/frontend/components/LedgerCloseChart.jsx b/frontend/components/LedgerCloseChart.jsx index f2ea24f0..27e70af0 100644 --- a/frontend/components/LedgerCloseChart.jsx +++ b/frontend/components/LedgerCloseChart.jsx @@ -1,47 +1,40 @@ import React from "react"; -import Panel from "muicss/lib/react/panel"; import axios from "axios"; -import * as d3 from "d3"; import D3BarChart from "./D3BarChart.jsx"; import each from "lodash/each"; import clone from "lodash/clone"; +import Card from "./ui/Card.jsx"; export default class LedgerChartClose 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, }; this.url = `${this.props.horizonURL}/ledgers?order=desc&limit=${this.props.limit}`; + this.tooltipTitle = (x) => `Ledger #${x}`; + this.valueFormat = (y) => `${Math.round(y * 100) / 100}s`; } componentDidMount() { this.getLedgers(); // Update chart width this.updateSize(); - setInterval(() => this.updateSize(), 5000); + this.sizeInterval = setInterval(() => this.updateSize(), 5000); + } + + componentWillUnmount() { + clearInterval(this.sizeInterval); + if (this.newLedgerListener) { + this.newLedgerListener.remove(); + } } updateSize() { - let value = this.panel.offsetWidth - 20; + let value = this.panel.offsetWidth - 42; if (this.state.chartWidth != value) { this.setState({ chartWidth: value }); } @@ -51,7 +44,7 @@ export default class LedgerChartClose extends React.Component { axios.get(this.url).then((response) => { let data = [ { - label: "Ledger Close", + label: "Close time", values: [], }, ]; @@ -69,7 +62,7 @@ export default class LedgerChartClose extends React.Component { }); this.setState({ loading: false, data }); // Start listening to events - this.props.emitter.addListener( + this.newLedgerListener = this.props.emitter.addListener( this.props.newLedgerEventName, this.onNewLedger.bind(this), ); @@ -98,25 +91,26 @@ export default class LedgerChartClose extends React.Component { this.panel = el; }} > - -
    - Last {this.props.limit} ledgers close times: {this.props.network} - - API - -
    + {this.state.loading ? ( - "Loading..." +
    ) : ( )} -
    +
); } diff --git a/frontend/components/ListAccounts.jsx b/frontend/components/ListAccounts.jsx index 3c96f0a8..871f8dce 100644 --- a/frontend/components/ListAccounts.jsx +++ b/frontend/components/ListAccounts.jsx @@ -1,11 +1,10 @@ import React from "react"; -import Panel from "muicss/lib/react/panel"; import axios from "axios"; -import clone from "lodash/clone"; import find from "lodash/find"; import reduce from "lodash/reduce"; import AccountBadge from "./AccountBadge.jsx"; import BigNumber from "bignumber.js"; +import Card from "./ui/Card.jsx"; export default class ListAccounts extends React.Component { constructor(props) { @@ -45,21 +44,20 @@ export default class ListAccounts extends React.Component { } render() { - let sum = _.reduce( + let sum = reduce( this.state.balances, - (sum, balance) => sum.add(balance), + (acc, balance) => acc.add(balance), new BigNumber(0), ); return ( - -
List of accounts: {this.props.label}
+ {sum.gt(0) ? ( - +
- + @@ -72,9 +70,9 @@ export default class ListAccounts extends React.Component { id={key} /> - -
AccountBalanceBalance
+ {typeof this.state.balances[key] === "undefined" - ? "Loading..." + ? "—" : `${this.state.balances[key].toFormat( 0, BigNumber.ROUND_FLOOR, @@ -87,16 +85,19 @@ export default class ListAccounts extends React.Component {
Sum + {sum.toFormat(0, BigNumber.ROUND_FLOOR)} XLM
) : ( - "Loading..." +
+ + +
)} -
+ ); } } diff --git a/frontend/components/LumensCirculating.jsx b/frontend/components/LumensCirculating.jsx index 93f0482c..e94d52ad 100644 --- a/frontend/components/LumensCirculating.jsx +++ b/frontend/components/LumensCirculating.jsx @@ -1,7 +1,5 @@ import React from "react"; import AmountWidget from "./AmountWidget.jsx"; -import BigNumber from "bignumber.js"; -import Panel from "muicss/lib/react/panel"; import * as lumens from "../../common/lumens.js"; const { circulatingSupply } = lumens; @@ -29,14 +27,11 @@ export default class LumensCirculating extends AmountWidget { }); } - renderName() { - return ( -
- Circulating Supply - - API - -
- ); + name() { + return "Circulating supply"; + } + + apiUrl() { + return "/api/v2/lumens/"; } } diff --git a/frontend/components/LumensDistributed.jsx b/frontend/components/LumensDistributed.jsx index d682ed2c..b2d72d1a 100644 --- a/frontend/components/LumensDistributed.jsx +++ b/frontend/components/LumensDistributed.jsx @@ -1,9 +1,5 @@ import React from "react"; 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 * as lumens from "../../common/lumens.js"; const { distributionAll } = lumens; @@ -27,14 +23,11 @@ export default class LumensDistributed extends AmountWidget { }); } - renderName() { - return ( -
- Lumens Distributed - - API - -
- ); + name() { + return "Lumens distributed"; + } + + apiUrl() { + return "/api/lumens"; } } diff --git a/frontend/components/LumensNonCirculating.jsx b/frontend/components/LumensNonCirculating.jsx index d5089ee3..536b2818 100644 --- a/frontend/components/LumensNonCirculating.jsx +++ b/frontend/components/LumensNonCirculating.jsx @@ -1,6 +1,5 @@ import React from "react"; import AmountWidget from "./AmountWidget.jsx"; -import Panel from "muicss/lib/react/panel"; import * as lumens from "../../common/lumens.js"; const { noncirculatingSupply } = lumens; @@ -24,14 +23,11 @@ export default class LumensNonCirculating extends AmountWidget { }); } - renderName() { - return ( -
- Non-Circulating Supply - - API - -
- ); + name() { + return "Non-circulating supply"; + } + + apiUrl() { + return "/api/v2/lumens/"; } } diff --git a/frontend/components/NetworkStatus.jsx b/frontend/components/NetworkStatus.jsx index aff3e3f5..3fd72e3c 100644 --- a/frontend/components/NetworkStatus.jsx +++ b/frontend/components/NetworkStatus.jsx @@ -1,12 +1,15 @@ import React from "react"; -import Panel from "muicss/lib/react/panel"; import axios from "axios"; import round from "lodash/round"; import { ago, agoSeconds } from "../common/time"; +import { copyStatusCard } from "./ui/statusCard.js"; +import logoUrl from "../assets/stellar-logo-white.svg"; // ledgersInAverageCalculation defines how many last ledgers should be // considered when calculating average ledger length. const ledgersInAverageCalculation = 200; +// opsRateWindow defines how many recent ledgers feed the ops/min figure. +const opsRateWindow = 20; export default class NetworkStatus extends React.Component { constructor(props) { @@ -32,15 +35,47 @@ export default class NetworkStatus extends React.Component { new Date(this.records[1].closed_at)) / 1000; this.records.pop(); + // Protocol upgrades are the moment this page gets projected on big + // screens — flash the stat when the version flips. + if ( + this.state.protocolVersion && + protocolVersion !== this.state.protocolVersion + ) { + clearTimeout(this.protocolFlashTimer); + this.setState({ protocolFlash: true }); + this.protocolFlashTimer = setTimeout( + () => this.setState({ protocolFlash: false }), + 6000, + ); + } this.setState({ closedAt, + closedAgo: agoSeconds(closedAt), lastLedgerSequence, lastLedgerLength, ledgerLengthSum, protocolVersion, + opsPerMinute: this.opsPerMinute(), }); } + // Operations per minute over the most recent ledgers. + opsPerMinute() { + const window = this.records.slice(0, opsRateWindow); + if (window.length < 2) { + return null; + } + const ops = window.reduce( + (sum, ledger) => sum + (ledger.operation_count || 0), + 0, + ); + const minutes = + (new Date(window[0].closed_at) - + new Date(window[window.length - 1].closed_at)) / + 60000; + return minutes > 0 ? Math.round(ops / minutes) : null; + } + getLastLedgers() { axios .get( @@ -66,14 +101,18 @@ export default class NetworkStatus extends React.Component { this.setState({ closedAt, + // Set closedAgo right away — waiting for the next 1s timer tick + // briefly renders a wrong "network slow" status on first paint. + closedAgo: agoSeconds(closedAt), lastLedgerLength, lastLedgerSequence, ledgerLengthSum, protocolVersion, + opsPerMinute: this.opsPerMinute(), loading: false, }); // Start listening to events - this.props.emitter.addListener( + this.newLedgerListener = this.props.emitter.addListener( this.props.newLedgerEventName, this.onNewLedger.bind(this), ); @@ -91,85 +130,230 @@ export default class NetworkStatus extends React.Component { this.setState({ closedAgo }); }, 1000); + this.onKeyDown = (e) => { + if (e.key === "Escape" && this.props.live && this.props.onExitLive) { + this.props.onExitLive(); + } + }; + window.addEventListener("keydown", this.onKeyDown); this.getLastLedgers(); } componentWillUnmount() { clearInterval(this.timerID); + clearTimeout(this.shareResetTimer); + clearTimeout(this.protocolFlashTimer); + window.removeEventListener("keydown", this.onKeyDown); + if (this.newLedgerListener) { + this.newLedgerListener.remove(); + } + } + + shareCard(statusText, statusClass, averageLedgerLength) { + copyStatusCard({ + eyebrow: `${this.props.network} status`, + statusText, + statusClass, + stats: [ + { label: "Last ledger", value: `#${this.state.lastLedgerSequence}` }, + { + label: "Closed", + value: `~${ago(this.state.closedAt)} ago in ${ + this.state.lastLedgerLength / 1000 + }s`, + }, + { label: "Avg close", value: `${round(averageLedgerLength, 2)}s` }, + { label: "Protocol", value: `${this.state.protocolVersion}` }, + ], + }).then((result) => { + this.setState({ shareState: result }); + this.shareResetTimer = setTimeout( + () => this.setState({ shareState: null }), + 2000, + ); + }); + } + + // Fullscreen view for big screens: conference projections, wall monitors, + // protocol-upgrade watch parties. + renderLive(statusClass, statusText, averageLedgerLength) { + return ( +
+
+ Stellar +
{this.props.network}
+
+
+
+

{statusText}

+
+ {!this.state.loading ? ( +
+
+
Last ledger
+
#{this.state.lastLedgerSequence}
+
+
+
Avg close
+
+ {round(averageLedgerLength, 2)}s +
+
+ {this.state.opsPerMinute ? ( +
+
Ops per minute
+
+ {this.state.opsPerMinute.toLocaleString("en-US")} +
+
+ ) : null} +
+
Protocol
+
+ {this.state.protocolVersion} +
+
+
+ ) : null} +
+
dashboard.stellar.org
+ +
+
+ ); } render() { - let statusClass; + let statusClass = ""; let statusText; let averageLedgerLength = this.state.ledgerLengthSum / ledgersInAverageCalculation; if (this.state.loading) { - statusText = Loading...; + statusText = "Connecting…"; } else if (this.state.closedAgo >= 90) { // If last ledger closed more than 90 seconds ago it means network is down. statusClass = "down"; - statusText = ( - - Network (or monitoring node) down! - - ); + statusText = "Network (or monitoring node) down"; } else { // Now we check the average close time but we also need to check the latest ledger // close time because if there are no new ledgers it means that network is slow or down. if (averageLedgerLength <= 10 && this.state.closedAgo < 20) { - statusText = ( - - Up and running! - - ); + statusText = "Up and running"; } else if (averageLedgerLength <= 15 && this.state.closedAgo < 40) { statusClass = "slow"; - statusText = ( - - Network slow! - - ); + statusText = "Network slow"; } else { statusClass = "very-slow"; - statusText = ( - - Network very slow! - - ); + statusText = "Network very slow"; } } + if (this.props.live) { + return this.renderLive(statusClass, statusText, averageLedgerLength); + } + return ( - -
Network Status: {this.props.network}
-
- {/* Fancy pulse effect */} -
-
-
+
+
+
+
+ {this.props.network} status + {!this.state.loading ? ( + + ) : null} + {!this.state.loading && this.props.onEnterLive ? ( + + ) : null} +
+
+
+

{statusText}

+
-
-
- {statusText} -
{!this.state.loading ? ( -
- Protocol version: {this.state.protocolVersion} -
- Last ledger: #{this.state.lastLedgerSequence} closed ~ - {ago(this.state.closedAt)} ago in{" "} - {this.state.lastLedgerLength / 1000}s. -
- Average ledger close time in the last{" "} - {ledgersInAverageCalculation} ledgers:{" "} - {round(averageLedgerLength, 2)}s. +
+
+
Last ledger
+
+ #{this.state.lastLedgerSequence} +
+
+
+
Closed
+
+ ~{ago(this.state.closedAt)} ago in{" "} + {this.state.lastLedgerLength / 1000}s +
+
+
+
+ Avg close · {ledgersInAverageCalculation} ledgers +
+
+ {round(averageLedgerLength, 2)}s +
+
+ {this.state.opsPerMinute ? ( +
+
Ops per minute
+
+ {this.state.opsPerMinute.toLocaleString("en-US")} +
+
+ ) : null} +
+
Protocol
+
{this.state.protocolVersion}
+
) : ( - "" +
+ +
)}
- +
); } } diff --git a/frontend/components/PublicNetworkLedgersHistoryChart.jsx b/frontend/components/PublicNetworkLedgersHistoryChart.jsx index d20c02c5..c0c9e32d 100644 --- a/frontend/components/PublicNetworkLedgersHistoryChart.jsx +++ b/frontend/components/PublicNetworkLedgersHistoryChart.jsx @@ -1,30 +1,14 @@ 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"; +import Card from "./ui/Card.jsx"; export default class PublicNetworkLedgersHistoryChart 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, @@ -34,14 +18,19 @@ export default class PublicNetworkLedgersHistoryChart extends React.Component { componentDidMount() { this.getLedgers(); - setInterval(() => this.getLedgers(), 1000 * 60 * 5); + this.refreshInterval = setInterval(() => this.getLedgers(), 1000 * 60 * 5); // Update chart width this.updateSize(); - setInterval(() => this.updateSize(), 5000); + this.sizeInterval = setInterval(() => this.updateSize(), 5000); + } + + componentWillUnmount() { + clearInterval(this.sizeInterval); + clearInterval(this.refreshInterval); } updateSize() { - let value = this.panel.offsetWidth - 20; + let value = this.panel.offsetWidth - 42; if (this.state.chartWidth != value) { this.setState({ chartWidth: value }); } @@ -74,27 +63,38 @@ export default class PublicNetworkLedgersHistoryChart extends React.Component { this.panel = el; }} > - -
- Txs &{" "} - Ops in - the last 30 days: Live Network -
+ + + Txs + + + Ops + +
+ } + > {this.state.loading ? ( - "Loading..." +
) : ( )} - +
); } diff --git a/frontend/components/RecentOperations.jsx b/frontend/components/RecentOperations.jsx index 42bc8e8a..1f94365a 100644 --- a/frontend/components/RecentOperations.jsx +++ b/frontend/components/RecentOperations.jsx @@ -1,5 +1,4 @@ import React from "react"; -import Panel from "muicss/lib/react/panel"; import axios from "axios"; import moment from "moment"; import each from "lodash/each"; @@ -9,6 +8,7 @@ import LiquidityPoolBadge from "./LiquidityPoolBadge.jsx"; import AssetLink from "./AssetLink.jsx"; import BigNumber from "bignumber.js"; import { ago } from "../common/time"; +import Card from "./ui/Card.jsx"; export default class RecentOperations extends React.Component { constructor(props) { @@ -31,13 +31,20 @@ export default class RecentOperations extends React.Component { axios.get(this.url).then((response) => { let records = response.data._embedded.records; + // Track which rows are new so only those flash on arrival. + let knownIds = {}; + each(this.state.operations, (op) => { + knownIds[op.id] = true; + }); + let isFirstLoad = this.state.operations.length === 0; let operations = []; each(records, (operation) => { operation.createdAtMoment = moment(operation.created_at); operation.ago = ago(operation.createdAtMoment); + operation.fresh = !isFirstLoad && !knownIds[operation.id]; operations.push(operation); }); - this.setState({ operations }); + this.setState({ operations, loading: false }); this.operationsLoading = false; }); } @@ -231,55 +238,56 @@ export default class RecentOperations extends React.Component { render() { return ( - -
- Recent operations: {this.props.label}{" "} - {this.props.account ? this.props.account.substr(0, 4) : ""} - - API - -
- - - - - - - - - - + + {this.state.loading ? ( +
+ + + +
+ ) : ( +
    {this.state.operations.map((op) => { return ( -
- - - - - + + {op.ago ? op.ago : "…"} + + +
+ {" "} + {this.operationTypeColRender(op)} +
+ ); })} - -
SourceOperationDetailsTime ago
- - - +
  • +
  • {this.operationTypeColRender(op)} - {op.ago ? ( - {op.ago} - ) : ( - "..." - )} -
    -
    + + )} + ); } } diff --git a/frontend/components/ScheduledMaintenance.jsx b/frontend/components/ScheduledMaintenance.jsx index 93017840..91f435cd 100644 --- a/frontend/components/ScheduledMaintenance.jsx +++ b/frontend/components/ScheduledMaintenance.jsx @@ -1,5 +1,4 @@ import React from "react"; -import Panel from "muicss/lib/react/panel"; import moment from "moment"; import sanitizeHtml from "../utilities/sanitizeHtml.js"; @@ -13,16 +12,12 @@ export const ScheduledMaintenance = ({ scheduledMaintenances }) => { incident_updates: updates, scheduled_for, } = sortedMaintenances[0]; - const scheduledFor = moment(scheduled_for); return ( - -
    - Scheduled Maintenance:{" "} - - {name} - {" "} - on{" "} +
    + Scheduled maintenance:{" "} + {name} +
    {moment(scheduled_for).utc().format("dddd, MMMM Do YYYY, [at] h:mma")}{" "} UTC ( {moment(scheduled_for).format( @@ -32,12 +27,8 @@ export const ScheduledMaintenance = ({ scheduledMaintenances }) => { : "MMMM Do YYYY, h:mma", )}{" "} local time) -
    - {updates.length > 0 ? ( - {sanitizeHtml(updates[0].body)} - ) : null} -
    - + {updates.length > 0 ?
    {sanitizeHtml(updates[0].body)}
    : null} +
    ); }; diff --git a/frontend/components/SupplyDistribution.jsx b/frontend/components/SupplyDistribution.jsx new file mode 100644 index 00000000..420d4a56 --- /dev/null +++ b/frontend/components/SupplyDistribution.jsx @@ -0,0 +1,134 @@ +import React from "react"; +import axios from "axios"; +import BigNumber from "bignumber.js"; +import Card from "./ui/Card.jsx"; +import { createTooltip, tooltipRow } from "./ui/chartUtils.js"; + +// Segment colors validated (CVD/contrast, dark & light) with the segment +// order below — order and color travel together. +const SEGMENTS = [ + { key: "circulating", label: "Circulating", color: "#b8860b" }, + { key: "directDevelopment", label: "Direct development", color: "#00a7b5" }, + { + key: "productAndInnovation", + label: "Product & innovation", + color: "#be6a8a", + }, + { key: "growth", label: "Growth", color: "#8f7fd6" }, + { key: "assetsAndLiquidity", label: "Assets & liquidity", color: "#5e9732" }, + { key: "other", label: "Other non-circulating", color: "#6e6e6e" }, +]; + +function formatBillions(value) { + return `${new BigNumber(value).div(1e9).toFormat(2)}B`; +} + +export default class SupplyDistribution extends React.Component { + constructor(props) { + super(props); + this.state = { loading: true }; + } + + componentDidMount() { + this.tooltip = createTooltip(); + this.timerID = setInterval(() => this.loadSupply(), 60 * 60 * 1000); + this.loadSupply(); + } + + componentWillUnmount() { + clearInterval(this.timerID); + this.tooltip.destroy(); + } + + showSegmentTooltip(event, segment, value, pct) { + this.tooltip.show( + `
    ${segment.label}
    ` + + tooltipRow( + segment.color, + "XLM", + `${formatBillions(value)} · ${pct.toFixed(1)}%`, + ), + event.clientX, + event.clientY, + ); + } + + loadSupply() { + axios.get("/api/lumens").then((response) => { + const { totalCoins, availableCoins, programs } = response.data; + const total = new BigNumber(totalCoins); + const circulating = new BigNumber(availableCoins); + const programValues = { + directDevelopment: new BigNumber(programs.directDevelopment), + productAndInnovation: new BigNumber(programs.productAndInnovation), + growth: new BigNumber(programs.growth), + assetsAndLiquidity: new BigNumber(programs.assetsAndLiquidity), + }; + const accounted = Object.values(programValues).reduce( + (sum, v) => sum.plus(v), + circulating, + ); + const other = BigNumber.max(total.minus(accounted), 0); + + this.setState({ + loading: false, + total, + values: { circulating, ...programValues, other }, + }); + }); + } + + render() { + return ( + + {this.state.loading ? ( +
    + ) : ( +
    +
    + {SEGMENTS.map((segment) => { + const value = this.state.values[segment.key]; + const pct = value.div(this.state.total).times(100).toNumber(); + return ( +
    + this.showSegmentTooltip(e, segment, value, pct) + } + onMouseLeave={() => this.tooltip.hide()} + >
    + ); + })} +
    +
    + {SEGMENTS.map((segment) => { + const value = this.state.values[segment.key]; + const pct = value.div(this.state.total).times(100).toNumber(); + return ( +
    + + {segment.label} + + {formatBillions(value)} · {pct.toFixed(1)}% + +
    + ); + })} +
    +
    + )} +
    + ); + } +} diff --git a/frontend/components/TotalCoins.jsx b/frontend/components/TotalCoins.jsx index 5ded3331..ffc7b3d7 100644 --- a/frontend/components/TotalCoins.jsx +++ b/frontend/components/TotalCoins.jsx @@ -24,14 +24,11 @@ export default class TotalCoins extends AmountWidget { }); } - renderName() { - return ( -
    - Total Supply - - API - -
    - ); + name() { + return "Total supply"; + } + + apiUrl() { + return "/api/v2/lumens/"; } } diff --git a/frontend/components/TransactionsChart.jsx b/frontend/components/TransactionsChart.jsx index e6a2b3a8..d5040687 100644 --- a/frontend/components/TransactionsChart.jsx +++ b/frontend/components/TransactionsChart.jsx @@ -1,30 +1,15 @@ 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"; +import Card from "./ui/Card.jsx"; 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, @@ -33,17 +18,25 @@ export default class TransactionsChart extends React.Component { yAxisStep: 50, // Default value, will be updated dynamically }; this.url = `${this.props.horizonURL}/ledgers?order=desc&limit=${this.props.limit}`; + this.tooltipTitle = (x) => `Ledger #${x}`; } componentDidMount() { this.getLedgers(); // Update chart width this.updateSize(); - setInterval(() => this.updateSize(), 5000); + this.sizeInterval = setInterval(() => this.updateSize(), 5000); + } + + componentWillUnmount() { + clearInterval(this.sizeInterval); + if (this.newLedgerListener) { + this.newLedgerListener.remove(); + } } updateSize() { - let value = this.panel.offsetWidth - 20; + let value = this.panel.offsetWidth - 42; if (this.state.chartWidth != value) { this.setState({ chartWidth: value }); } @@ -79,7 +72,7 @@ export default class TransactionsChart extends React.Component { // Determine step size based on network type let stepSize; - if (this.props.network === "Test network") { + if (this.props.network === "Testnet") { stepSize = 1; // Test network uses step size of 1 } else { // Live network: choose between 50 and 100 based on resulting tick count @@ -91,7 +84,7 @@ export default class TransactionsChart extends React.Component { // Ensure minimum values for better chart readability let minYAxisMax; - if (this.props.network === "Test network") { + if (this.props.network === "Testnet") { minYAxisMax = 10; // Smaller minimum for test network } else { minYAxisMax = stepSize === 50 ? 100 : 200; @@ -150,7 +143,7 @@ export default class TransactionsChart extends React.Component { this.setState({ loading: false, data, yAxisMax, yAxisStep }); // Start listening to events - this.props.emitter.addListener( + this.newLedgerListener = this.props.emitter.addListener( this.props.newLedgerEventName, this.onNewLedger.bind(this), ); @@ -164,31 +157,38 @@ export default class TransactionsChart extends React.Component { this.panel = el; }} > - -
    - Successful{" "} - Txs &{" "} - Ops in - the last {this.props.limit} ledgers: {this.props.network} - - API - -
    + + + Txs + + + Ops + +
    + } + > {this.state.loading ? ( - "Loading..." +
    ) : ( )} -
    + ); } diff --git a/frontend/components/ui/Card.jsx b/frontend/components/ui/Card.jsx new file mode 100644 index 00000000..c3f13c68 --- /dev/null +++ b/frontend/components/ui/Card.jsx @@ -0,0 +1,29 @@ +import React from "react"; + +// Replaces the muicss Panel. Standardizes the widget header that the old +// components hand-rolled (".widget-name" + floating ".api-link"). +export default function Card({ title, apiUrl, tag, children, className }) { + const hasHeader = title || apiUrl || tag; + return ( +
    + {hasHeader ? ( +
    + {title ?

    {title}

    : null} + {tag} + {apiUrl ? ( + + API + + ) : null} +
    + ) : null} +
    {children}
    +
    + ); +} diff --git a/frontend/components/ui/chartUtils.js b/frontend/components/ui/chartUtils.js new file mode 100644 index 00000000..49fb4c45 --- /dev/null +++ b/frontend/components/ui/chartUtils.js @@ -0,0 +1,77 @@ +// Shared helpers for the D3 charts: resolve the token palette at render time +// (CSS custom properties can't be read by D3 directly) and build tooltip DOM. + +export function getChartTheme(element) { + // Resolve from the chart's own node so scoped overrides (e.g. the May 4 + // force theme on #main.force) cascade into the palette. + const styles = getComputedStyle(element || document.documentElement); + const read = (name, fallback) => { + const value = styles.getPropertyValue(name).trim(); + return value || fallback; + }; + return { + primary: read("--chart-primary", "#fdda24"), + seriesA: read("--chart-series-a", "#00a7b5"), + seriesB: read("--chart-series-b", "#8f7fd6"), + grid: read("--chart-grid", "rgba(246, 247, 248, 0.07)"), + axisText: read("--chart-axis-text", "#6e6e6e"), + }; +} + +// SVG path for a bar with rounded top corners, anchored to the baseline. +export function roundedTopRect(x, y, width, height, radius) { + const r = Math.min(radius, width / 2, height); + if (height <= 0) { + return ""; + } + return [ + `M${x},${y + height}`, + `V${y + r}`, + `Q${x},${y} ${x + r},${y}`, + `H${x + width - r}`, + `Q${x + width},${y} ${x + width},${y + r}`, + `V${y + height}`, + "Z", + ].join(""); +} + +export function createTooltip() { + const el = document.createElement("div"); + el.className = "chart-tooltip"; + el.style.display = "none"; + document.body.appendChild(el); + + return { + show(html, clientX, clientY) { + el.innerHTML = html; + el.style.display = "block"; + const rect = el.getBoundingClientRect(); + let left = clientX + 12; + let top = clientY - rect.height - 12; + if (left + rect.width > window.innerWidth - 8) { + left = clientX - rect.width - 12; + } + if (top < 8) { + top = clientY + 12; + } + el.style.left = `${left}px`; + el.style.top = `${top}px`; + }, + hide() { + el.style.display = "none"; + }, + destroy() { + el.remove(); + }, + }; +} + +export function tooltipRow(color, label, value) { + return ( + `
    ` + + `` + + `${label}` + + `${value}` + + `
    ` + ); +} diff --git a/frontend/components/ui/statusCard.js b/frontend/components/ui/statusCard.js new file mode 100644 index 00000000..ac5e0af0 --- /dev/null +++ b/frontend/components/ui/statusCard.js @@ -0,0 +1,174 @@ +import logoUrl from "../../assets/stellar-logo-white.svg"; + +// Renders a shareable 1200×630 status card PNG. The community screenshots +// the network status to share moments like protocol upgrades — this gives +// them a clean, branded image in one click instead of a cropped screenshot. +// The card follows the theme the page was in when it was copied. + +const W = 1200; +const H = 630; +const SCALE = 2; + +const THEMES = { + dark: { + bg: "#0f0f0f", + text: "#f6f7f8", + secondary: "#a8a8a8", + faint: "#6e6e6e", + link: "#fdda24", + invertLogo: false, + status: { + "": "#fdda24", + slow: "#ff9f0a", + "very-slow": "#e5484d", + down: "#e5484d", + }, + }, + light: { + bg: "#f6f7f8", + text: "#0f0f0f", + secondary: "#555555", + faint: "#8a8a8a", + link: "#002e5d", + invertLogo: true, + // brand gold reads too faint on light — use the validated darker step + status: { + "": "#b8860b", + slow: "#ff9f0a", + "very-slow": "#e5484d", + down: "#e5484d", + }, + }, +}; + +function loadImage(src) { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = src; + }); +} + +export async function renderStatusCard({ + eyebrow, + statusText, + statusClass, + stats, +}) { + const themeName = + document.documentElement.getAttribute("data-theme") === "light" + ? "light" + : "dark"; + const theme = THEMES[themeName]; + + // Make sure the brand fonts are usable inside the canvas first. + await Promise.all([ + document.fonts.load("500 84px Lora"), + document.fonts.load("600 26px Inter"), + document.fonts.load("400 40px Inter"), + ]); + const logo = await loadImage(logoUrl); + + const canvas = document.createElement("canvas"); + canvas.width = W * SCALE; + canvas.height = H * SCALE; + const ctx = canvas.getContext("2d"); + ctx.scale(SCALE, SCALE); + + // Surface + ctx.fillStyle = theme.bg; + ctx.fillRect(0, 0, W, H); + + const left = 80; + + // Eyebrow + ctx.fillStyle = theme.secondary; + ctx.font = "600 26px Inter"; + ctx.fillText(eyebrow.toUpperCase(), left, 130); + + // Status dot + word + ctx.fillStyle = theme.status[statusClass] || theme.status[""]; + ctx.beginPath(); + ctx.arc(left + 16, 216, 16, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = theme.text; + ctx.font = "500 84px Lora"; + ctx.fillText(statusText, left + 56, 244); + + // Stats + const statTop = 380; + let x = left; + stats.forEach((stat) => { + ctx.fillStyle = theme.faint; + ctx.font = "600 20px Inter"; + ctx.fillText(stat.label.toUpperCase(), x, statTop); + ctx.fillStyle = theme.text; + ctx.font = "400 40px Inter"; + ctx.fillText(stat.value, x, statTop + 52); + x += + Math.max( + ctx.measureText(stat.value).width, + ctx.measureText(stat.label).width, + ) + 64; + }); + + // Footer: logo, source, timestamp + const footerY = 552; + const logoH = 36; + if (theme.invertLogo) { + // The brand asset is the white logo; flip it to black for light cards. + ctx.filter = "invert(1)"; + } + ctx.drawImage( + logo, + left, + footerY - logoH + 8, + (logo.width / logo.height) * logoH, + logoH, + ); + ctx.filter = "none"; + ctx.fillStyle = theme.link; + ctx.font = "500 24px Inter"; + ctx.fillText("dashboard.stellar.org", left + 200, footerY); + ctx.fillStyle = theme.faint; + ctx.font = "400 22px Inter"; + const ts = new Date().toISOString().replace("T", " ").slice(0, 16) + " UTC"; + ctx.textAlign = "right"; + ctx.fillText(ts, W - left, footerY); + ctx.textAlign = "left"; + + // toBlob calls back with null if the browser can't encode — reject so + // callers can surface a failure instead of throwing on a null Blob. + return new Promise((resolve, reject) => + canvas.toBlob((blob) => { + if (blob) { + resolve(blob); + } else { + reject(new Error("Could not render the status image")); + } + }, "image/png"), + ); +} + +export async function copyStatusCard(options) { + let blob; + try { + blob = await renderStatusCard(options); + } catch (e) { + return "error"; + } + try { + await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]); + return "copied"; + } catch (e) { + // Clipboard image support missing (e.g. Firefox) — download instead. + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "stellar-network-status.png"; + a.click(); + URL.revokeObjectURL(url); + return "downloaded"; + } +} diff --git a/frontend/main.jsx b/frontend/main.jsx index 0f971c0e..fefc87bf 100644 --- a/frontend/main.jsx +++ b/frontend/main.jsx @@ -2,6 +2,13 @@ import React from "react"; import { createRoot } from "react-dom/client"; import App from "./components/App.jsx"; +// Self-hosted brand fonts (Stellar Brand Guidelines) +import "@fontsource/inter/400.css"; +import "@fontsource/inter/500.css"; +import "@fontsource/inter/600.css"; +import "@fontsource/lora/500.css"; +import "@fontsource/lora/600.css"; + // Add SCSS import import "./scss/index.scss"; diff --git a/frontend/scss/_base.scss b/frontend/scss/_base.scss new file mode 100644 index 00000000..efdc7dc1 --- /dev/null +++ b/frontend/scss/_base.scss @@ -0,0 +1,159 @@ +// Base layer: resets, typography, layout primitives. + +* { + box-sizing: border-box; +} + +html { + -webkit-text-size-adjust: 100%; +} + +body { + margin: 0; + background-color: var(--color-bg); + color: var(--color-text); + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.45; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +::selection { + background: var(--color-accent); + color: var(--color-accent-contrast); +} + +a { + color: var(--color-link); + text-decoration: none; + transition: color var(--transition-fast); + + &:hover { + text-decoration: underline; + text-underline-offset: 3px; + } +} + +:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 2px; + border-radius: 2px; +} + +h1, +h2, +h3, +h4 { + margin: 0; +} + +// Data everywhere: keep digits aligned as values update live. +td, +th, +.tabular { + font-variant-numeric: tabular-nums; +} + +// --------------------------------------------------------------------------- +// Layout + +.container { + max-width: 1440px; + margin-inline: auto; + padding-inline: 24px; + + @media (max-width: 600px) { + padding-inline: 16px; + } +} + +.grid { + display: grid; + grid-template-columns: repeat(12, 1fr); + gap: 24px; + align-items: start; + + // Never let a fixed-width child (chart SVGs) blow the grid out. + > * { + min-width: 0; + } + + @media (max-width: 900px) { + grid-template-columns: 1fr; + gap: 16px; + } +} + +.col-4 { + grid-column: span 4; + + @media (max-width: 900px) { + grid-column: 1 / -1; + } +} + +.col-8 { + grid-column: span 8; + + @media (max-width: 900px) { + grid-column: 1 / -1; + } +} + +.col-12 { + grid-column: 1 / -1; +} + +// Widgets stacked inside a grid column. +.stack > * + * { + margin-top: 24px; + + @media (max-width: 900px) { + margin-top: 16px; + } +} + +// --------------------------------------------------------------------------- +// Sections + +.section { + padding-block: 40px 8px; + + @media (max-width: 600px) { + padding-block: 28px 4px; + } +} + +.section-title { + font-family: var(--font-serif); + font-weight: 600; + font-size: 28px; + line-height: 1.2; + letter-spacing: -0.01em; + margin-bottom: 24px; + + // Short gold rule above the title — the one brand flourish per section. + &::before { + content: ""; + display: block; + width: 24px; + height: 3px; + margin-bottom: 12px; + background: var(--color-accent); + } +} + +.section-subtitle { + color: var(--color-text-secondary); + font-size: 14px; + max-width: 560px; + margin-top: -16px; + margin-bottom: 24px; +} + +.section-footnote { + color: var(--color-text-faint); + font-size: 12px; + margin: 16px 0 0; +} diff --git a/frontend/scss/_charts.scss b/frontend/scss/_charts.scss new file mode 100644 index 00000000..466d9c88 --- /dev/null +++ b/frontend/scss/_charts.scss @@ -0,0 +1,38 @@ +// Chart layer: D3 SVG styling driven by the token palette. + +.chart-container { + padding: 12px 12px 4px; +} + +.axis { + text { + font-family: var(--font-sans); + font-size: 11px; + fill: var(--chart-axis-text); + } + + // No spines: gridlines carry the scale. + path.domain, + line { + stroke: none; + } +} + +.chart-grid line { + stroke: var(--chart-grid); + shape-rendering: crispedges; +} + +.chart-tooltip { + position: fixed; + z-index: 200; + pointer-events: none; + background: var(--color-surface-raised); + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-chip); + padding: 6px 10px; + font-size: 12px; + color: var(--color-text); + font-variant-numeric: tabular-nums; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.35); +} diff --git a/frontend/scss/_components.scss b/frontend/scss/_components.scss new file mode 100644 index 00000000..04914aab --- /dev/null +++ b/frontend/scss/_components.scss @@ -0,0 +1,1020 @@ +// Component layer: header, cards, chips, tables, banners, status, skeletons. + +// --------------------------------------------------------------------------- +// Site header + +.site-header { + position: sticky; + top: 0; + z-index: 100; + background-color: var(--color-bg); + border-bottom: 1px solid var(--color-border); +} + +.site-header-inner { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + height: 64px; +} + +.site-brand { + display: flex; + align-items: center; + gap: 12px; + color: var(--color-text); + + &:hover { + text-decoration: none; + } + + .site-logo { + display: block; + height: 22px; + width: auto; + } + + .site-brand-suffix { + padding-left: 12px; + border-left: 1px solid var(--color-border-strong); + font-size: 13px; + letter-spacing: 0.02em; + color: var(--color-text-secondary); + white-space: nowrap; + + @media (max-width: 760px) { + display: none; + } + } +} + +// The extracted brand SVG is white; flip it for the light theme. +:root[data-theme="light"] .site-logo { + filter: invert(1); +} + +.site-nav { + display: flex; + align-items: center; + gap: 20px; + + a { + color: var(--color-text-secondary); + font-size: 13px; + + &:hover { + color: var(--color-text); + text-decoration: none; + } + } + + @media (max-width: 600px) { + gap: 12px; + + .hide-mobile { + display: none; + } + } +} + +.icon-button { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-chip); + background: transparent; + color: var(--color-text-secondary); + cursor: pointer; + transition: color var(--transition-fast), + border-color var(--transition-fast); + + &:hover { + color: var(--color-text); + border-color: var(--color-text-faint); + } + + svg { + width: 16px; + height: 16px; + } +} + +.site-header-left { + display: flex; + align-items: center; + gap: 20px; + min-width: 0; +} + +.network-switcher { + display: flex; + gap: 2px; + padding: 2px; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-chip); + + a { + padding: 3px 10px; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + color: var(--color-text-secondary); + + &:hover { + color: var(--color-text); + text-decoration: none; + } + + &.active { + background: var(--color-accent); + color: var(--color-accent-contrast); + } + } + + @media (max-width: 600px) { + a { + padding: 3px 7px; + font-size: 11px; + } + } +} + +// --------------------------------------------------------------------------- +// Network hero + +.network-hero { + border-bottom: 1px solid var(--color-border); +} + +.network-hero-inner { + display: flex; + align-items: flex-end; + justify-content: space-between; + gap: 32px; + padding-block: 36px 32px; + position: relative; + z-index: 1; + + @media (max-width: 1180px) { + flex-direction: column; + align-items: flex-start; + gap: 20px; + } +} + +.network-hero-eyebrow { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 10px; +} + +.share-button { + padding: 2px 8px; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-chip); + background: none; + font-family: var(--font-sans); + font-size: 11px; + font-weight: 500; + color: var(--color-text-faint); + cursor: pointer; + transition: color var(--transition-fast), + border-color var(--transition-fast), + opacity var(--transition-fast); + + &:hover { + color: var(--color-text); + border-color: var(--color-text-faint); + } +} + +// Only surface the share button while the hero is being explored. Devices +// without hover (touch) keep it visible, and keyboard focus always reveals it. +@media (hover: hover) { + .share-button { + opacity: 0; + } + + .network-hero:hover .share-button, + .share-button:focus-visible, + .share-button.active { + opacity: 1; + } +} + +.network-hero-status { + .stat-label { + margin-bottom: 10px; + } + + > .network-hero-eyebrow .stat-label { + margin-bottom: 0; + } + + .status-hero { + margin-bottom: 0; + gap: 16px; + } + + .status-dot { + width: 14px; + height: 14px; + } + + .status-word { + font-size: 40px; + letter-spacing: -0.01em; + + @media (max-width: 600px) { + font-size: 28px; + } + } +} + +.network-hero-stats { + display: flex; + gap: 36px; + flex-wrap: wrap; + + .stat-value { + font-size: 17px; + } + + @media (max-width: 900px) { + gap: 20px 28px; + } +} + +// --------------------------------------------------------------------------- +// Live mode (fullscreen big-screen view: conferences, wall monitors) + +.live-mode { + min-height: 100vh; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 56px; + padding: 48px; + background: var(--color-bg); + text-align: center; + + .live-mode-header { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; + } + + .live-mode-logo { + display: block; + height: clamp(28px, 3vw, 46px); + width: auto; + } + + .live-mode-eyebrow { + font-size: clamp(14px, 1.2vw, 20px); + font-weight: 600; + letter-spacing: 0.18em; + text-transform: uppercase; + color: var(--color-text-faint); + } + + .status-hero { + margin: 0; + gap: clamp(16px, 2vw, 32px); + justify-content: center; + } + + .status-dot { + width: clamp(16px, 1.8vw, 28px); + height: clamp(16px, 1.8vw, 28px); + } + + .status-word { + font-size: clamp(48px, 8vw, 140px); + letter-spacing: -0.015em; + } + + .live-mode-stats { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: clamp(28px, 5vw, 96px); + text-align: left; + + .stat-label { + font-size: clamp(12px, 1vw, 17px); + margin-bottom: 6px; + } + + .stat-value { + font-size: clamp(26px, 3vw, 52px); + font-variant-numeric: tabular-nums; + border-radius: var(--radius-chip); + } + } + + .live-mode-footer { + position: fixed; + bottom: 24px; + left: 50%; + translate: -50% 0; + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + } + + .live-mode-url { + font-size: clamp(13px, 1vw, 17px); + font-weight: 500; + color: var(--color-text-secondary); + } + + .live-mode-hint { + padding: 4px 10px; + border: none; + background: none; + font-family: var(--font-sans); + font-size: 12px; + color: var(--color-text-faint); + cursor: pointer; + opacity: 0.7; + + &:hover { + opacity: 1; + } + } +} + +// The applause moment: protocol version just changed. +@media (prefers-reduced-motion: no-preference) { + .stat-value.flash { + animation: protocol-flash 1.5s ease-out 4; + } +} + +@keyframes protocol-flash { + 0% { + color: inherit; + text-shadow: none; + } + + 25% { + color: var(--color-accent); + text-shadow: 0 0 24px rgba(253, 218, 36, 0.45); + } + + 100% { + color: inherit; + text-shadow: none; + } +} + +// --------------------------------------------------------------------------- +// Cards + +.card { + background-color: var(--color-surface); + border: 1px solid var(--color-border); + border-radius: var(--radius-card); + box-shadow: var(--shadow-card); + overflow: hidden; + transition: border-color var(--transition-fast); + min-width: 0; + + // Charts get a fixed pixel width via JS; while the resize observer catches + // up (e.g. after a viewport change) let them scale down instead of + // overflowing the layout. + svg { + max-width: 100%; + } + + &:hover { + border-color: var(--color-border-strong); + } +} + +.card-header { + display: flex; + align-items: center; + gap: 12px; + padding: 14px 20px; + border-bottom: 1px solid var(--color-border); +} + +.card-title { + flex: 1; + font-family: var(--font-sans); + font-size: 12px; + font-weight: 600; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--color-text-secondary); +} + +.card-api-link { + flex-shrink: 0; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + padding: 3px 8px; + border: 1px solid var(--color-accent); + border-radius: var(--radius-chip); + color: var(--color-accent); + transition: background-color var(--transition-fast), + color var(--transition-fast); + + &:hover { + background-color: var(--color-accent); + color: var(--color-accent-contrast); + text-decoration: none; + } +} + +:root[data-theme="light"] .card-api-link { + // gold-on-white fails contrast; use ink instead + border-color: var(--color-border-strong); + color: var(--color-text-secondary); + + &:hover { + background-color: var(--color-accent); + border-color: var(--color-accent); + color: var(--color-accent-contrast); + } +} + +.card-body { + padding: 16px 20px; + + &.flush { + padding: 0; + } +} + +.nodes-cta { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + color: var(--color-text-secondary); + + a { + white-space: nowrap; + font-weight: 500; + } + + @media (max-width: 600px) { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } +} + +// --------------------------------------------------------------------------- +// Chips & legends + +.chip { + display: inline-block; + font-size: 11px; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 2px 7px; + border: 1px solid var(--color-border-strong); + border-radius: var(--radius-chip); + color: var(--color-text-secondary); + white-space: nowrap; +} + +.legend { + display: flex; + align-items: center; + gap: 14px; + font-size: 12px; + color: var(--color-text-secondary); +} + +.legend-item { + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} + +.legend-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + + &.primary { + background: var(--chart-primary); + } + + &.series-a { + background: var(--chart-series-a); + } + + &.series-b { + background: var(--chart-series-b); + } +} + +// --------------------------------------------------------------------------- +// Tables + +.data-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + + th { + text-align: left; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-text-faint); + padding: 6px 0; + } + + td { + padding: 7px 0; + border-top: 1px solid var(--color-border); + color: var(--color-text); + vertical-align: top; + } + + tr:first-child td { + border-top: none; + } + + .num { + text-align: right; + font-variant-numeric: tabular-nums; + } + + .label { + color: var(--color-text-secondary); + } +} + +.mono { + font-family: var(--font-mono); + font-size: 12px; +} + +// --------------------------------------------------------------------------- +// Operations stream + +.ops-card .card-body { + max-height: 460px; + overflow-y: auto; +} + +.ops-list { + list-style: none; + margin: 0; + padding: 0; +} + +.ops-row { + padding: 10px 0; + border-top: 1px solid var(--color-border); + font-size: 13px; + + &:first-child { + border-top: none; + } +} + +@media (prefers-reduced-motion: no-preference) { + .ops-row.fresh { + animation: ops-flash 1.2s ease-out both; + } +} + +@keyframes ops-flash { + from { + background-color: rgba(253, 218, 36, 0.1); + } + + to { + background-color: transparent; + } +} + +.ops-row-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 4px; + + a.chip { + color: var(--color-text-secondary); + + &:hover { + color: var(--color-text); + border-color: var(--color-text-faint); + text-decoration: none; + } + } +} + +.ops-time { + color: var(--color-text-faint); + font-size: 12px; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} + +.ops-row-body { + color: var(--color-text-secondary); + line-height: 1.6; + overflow-wrap: anywhere; + + code { + font-family: var(--font-mono); + font-size: 12px; + color: var(--color-text); + } + + // Account/asset links read as data, not as calls to action — keep them + // quiet so gold stays scarce. + a { + color: var(--color-text); + + &:hover { + color: var(--color-link); + } + } +} + +.account-known { + display: inline-block; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.03em; + padding: 1px 6px; + border-radius: var(--radius-chip); + background: var(--color-border); +} + +:root[data-theme="light"] .account-known { + background: #ececec; +} + +// --------------------------------------------------------------------------- +// Banners (active incidents / maintenance) + +.banner { + border: 1px solid var(--color-border); + border-left: 3px solid var(--color-danger); + border-radius: var(--radius-chip); + background: var(--color-surface); + padding: 12px 16px; + margin-top: 16px; + font-size: 13px; + + a { + font-weight: 600; + } + + .banner-meta { + color: var(--color-text-secondary); + font-size: 12px; + margin-top: 2px; + } + + &.warning { + border-left-color: var(--color-warning); + } +} + +// --------------------------------------------------------------------------- +// Network status + +.status-hero { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 16px; +} + +.status-dot { + position: relative; + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--color-accent); + flex-shrink: 0; + + &::after { + content: ""; + position: absolute; + inset: -6px; + border-radius: 50%; + border: 1px solid var(--color-accent); + opacity: 0; + } + + &.slow { + background: var(--color-warning); + + &::after { + border-color: var(--color-warning); + } + } + + &.very-slow, + &.down { + background: var(--color-danger); + + &::after { + border-color: var(--color-danger); + } + } +} + +@media (prefers-reduced-motion: no-preference) { + .status-dot::after { + animation: status-ring 2.4s ease-out infinite; + } + + .status-dot.very-slow::after, + .status-dot.down::after { + animation-duration: 1.2s; + } +} + +@keyframes status-ring { + 0% { + transform: scale(0.4); + opacity: 0.9; + } + + 70% { + transform: scale(1.4); + opacity: 0; + } + + 100% { + opacity: 0; + } +} + +.status-word { + font-family: var(--font-serif); + font-weight: 500; + font-size: 24px; + line-height: 1.1; +} + +.status-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px 16px; +} + +.stat { + .stat-label { + font-size: 11px; + font-weight: 500; + letter-spacing: 0.06em; + text-transform: uppercase; + color: var(--color-text-faint); + margin-bottom: 2px; + } + + .stat-value { + font-size: 15px; + font-variant-numeric: tabular-nums; + } +} + +// --------------------------------------------------------------------------- +// Incident history + +.incident-list { + list-style: none; + margin: 0; + padding: 0; + font-size: 13px; + + li { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 12px; + padding: 7px 0; + border-top: 1px solid var(--color-border); + + &:first-child { + border-top: none; + } + + a { + color: var(--color-text); + min-width: 0; + + &:hover { + color: var(--color-link); + } + } + } + + .incident-time { + flex-shrink: 0; + color: var(--color-text-faint); + font-size: 12px; + white-space: nowrap; + } +} + +.incidents-card .card-body { + max-height: 300px; + overflow-y: auto; +} + +// --------------------------------------------------------------------------- +// Capacity meter (fee stats) + +.capacity-meter { + margin-bottom: 12px; +} + +.capacity-meter-head { + display: flex; + align-items: baseline; + justify-content: space-between; + margin-bottom: 6px; +} + +.capacity-meter-track { + height: 4px; + border-radius: 2px; + background: var(--color-border); + overflow: hidden; +} + +.capacity-meter-fill { + height: 100%; + border-radius: 2px; + background: var(--color-accent); + transition: width 400ms ease-out; +} + +.text-warning { + color: var(--color-warning); +} + +.text-danger { + color: var(--color-danger); +} + +.fee-summary { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin-bottom: 16px; +} + +.text-button { + display: inline-block; + margin-top: 12px; + padding: 0; + border: none; + background: none; + font-family: var(--font-sans); + font-size: 12px; + font-weight: 500; + color: var(--color-text-secondary); + cursor: pointer; + + &:hover { + color: var(--color-link); + } +} + +// --------------------------------------------------------------------------- +// Hero numbers (lumen supply) + +.amount-hero { + font-family: var(--font-serif); + font-weight: 500; + font-size: 38px; + line-height: 1.1; + letter-spacing: -0.01em; + font-variant-numeric: tabular-nums; + + .amount-unit { + font-family: var(--font-sans); + font-size: 14px; + font-weight: 400; + color: var(--color-text-faint); + margin-left: 6px; + letter-spacing: 0; + } + + @media (max-width: 1180px) { + font-size: 30px; + } +} + +.amount-precise { + margin-top: 4px; + color: var(--color-text-faint); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +// --------------------------------------------------------------------------- +// Supply distribution bar + +.supply-bar { + display: flex; + gap: 2px; + height: 24px; + border-radius: var(--radius-chip); + overflow: hidden; + margin-bottom: 14px; +} + +.supply-segment { + min-width: 2px; + transition: opacity var(--transition-fast); + + &:hover { + opacity: 0.85; + } +} + +.supply-legend { + display: flex; + flex-wrap: wrap; + gap: 8px 20px; + font-size: 12px; + color: var(--color-text-secondary); + + .supply-legend-value { + color: var(--color-text-faint); + font-variant-numeric: tabular-nums; + } +} + +// --------------------------------------------------------------------------- +// Skeletons + +.skeleton { + display: inline-block; + width: 100%; + height: 14px; + border-radius: 4px; + background: linear-gradient( + 100deg, + var(--color-border) 40%, + var(--color-border-strong) 50%, + var(--color-border) 60% + ); + background-size: 200% 100%; +} + +@media (prefers-reduced-motion: no-preference) { + .skeleton { + animation: skeleton-shimmer 1.4s linear infinite; + } +} + +@keyframes skeleton-shimmer { + from { + background-position: 120% 0; + } + + to { + background-position: -80% 0; + } +} + +// --------------------------------------------------------------------------- +// Footer + +.site-footer { + margin-top: 56px; + border-top: 1px solid var(--color-border); + padding: 24px 0 40px; + color: var(--color-text-faint); + font-size: 12px; + + a { + color: var(--color-text-secondary); + } +} diff --git a/frontend/scss/_force.scss b/frontend/scss/_force.scss index adee0466..908a30a4 100644 --- a/frontend/scss/_force.scss +++ b/frontend/scss/_force.scss @@ -1,73 +1,67 @@ -@import url('https://fonts.googleapis.com/css?family=Press+Start+2P'); +// The "Press Start 2P" font is self-hosted and loaded lazily by App.jsx +// only when the force theme activates (see turnOnForceThemeFont), so normal +// visits never pay for it and there's no font CDN request. +// May the 4th easter egg — see forceTheme in App.jsx. #main.force { background: black; h1.may4 { color: white; - font-family: 'Press Start 2P', cursive; + font-family: "Press Start 2P", cursive; font-size: 1em; text-align: center; + padding: 24px 0 8px; } - section > h1 { - color: white; - text-shadow: none; - } - - section > h2 { - color: white; - text-shadow: none; - } + --color-bg: #000; + --color-surface: #111; + --color-surface-raised: #1a1a1a; + --color-accent: #b20014; + --color-accent-contrast: #fff; + --color-link: #ff2237; + --chart-primary: #b20014; - a { - color: #b20014; - } - - .mui-appbar { - background-color: #7E0010; - - .icons i { - color: white; - } + .site-header { + background-color: #7e0010; + border-bottom-color: #a00016; - .icons a { + .site-nav a, + .site-brand-suffix { color: white; } } - .mui-panel { - background-color: #222; - color: white; + .section-title::before, + .capacity-meter-fill { + background: #b20014; } - .account-tag { - background-color: black; - } - - .tag { - color: #b20014; - } - - .api-link { - background-color: #7E0010; - color: white; - } + .card-api-link { + border-color: #b20014; + color: #ff2237; - .recharts-wrapper { - background-color: white; + &:hover { + background-color: #b20014; + color: white; + } } - footer { + .account-tag { background-color: black; - color: white; } @mixin glow($color) { @keyframes glow_#{$color} { - from { color: #{$color}; box-shadow: 0 0 9px #{$color}; } - 50% { color: #{$color}; box-shadow: 0 0 50px #{$color}; } - to { color: #{$color}; box-shadow: 0 0 9px #{$color}; } + from { + box-shadow: 0 0 6px #{$color}; + } + 50% { + box-shadow: 0 0 24px #{$color}; + } + to { + box-shadow: 0 0 6px #{$color}; + } } } @@ -76,59 +70,31 @@ @include glow("red"); @include glow("gray"); - .pulse-container { - width: 180px; - height: 30px; - position: relative; - margin-left: auto; - margin-right: auto; - margin-top: 30px; - - .pulse { - opacity: 1; - width: 180px; - height: 9px; - background-color: white; - - &.pulse1 { - animation: glow_lime 1.5s linear infinite; - box-shadow: 0px 0px 10px, 0px 0px 2px inset; - border-top-left-radius: 0; - border-top-right-radius: 20px 10px; - border-bottom-right-radius: 20px 10px; - border-bottom-left-radius: 0; - } - - &.pulse2 { - animation: none; - height: 5px; - margin-top: 2px; - width: 30px; - margin-left: -30px; - border-right: 1px solid black; - - border-top-left-radius: 2px; - border-top-right-radius: 0; - border-bottom-right-radius: 0; - border-bottom-left-radius: 2px; + // Lightsaber blades instead of status dots. + .status-dot { + width: 120px; + height: 8px; + border-radius: 0 12px 12px 0; + background: lime; + animation: glow_lime 1.5s linear infinite; - @include prefixer(background, linear-gradient(to right, #fff 0%, #999 10%, #fff 20%, #999 30%, #fff 40%, #999 50%, #fff 70%, #999 70%, #fff 80%, #999 90%, #fff 100%), webkit moz spec); - } + &::after { + display: none; + } - &.pulse1.slow { - animation: glow_orange 1.5s linear infinite; - background-color: orange; - } + &.slow { + background: orange; + animation-name: glow_orange; + } - &.pulse1.very-slow { - animation: glow_red 1.5s linear infinite; - background-color: red; - } + &.very-slow { + background: red; + animation-name: glow_red; + } - &.pulse1.down { - animation: glow_gray 1.5s linear infinite; - background-color: black; - } + &.down { + background: gray; + animation-name: glow_gray; } } } diff --git a/frontend/scss/_main.scss b/frontend/scss/_main.scss deleted file mode 100644 index 7ff01c42..00000000 --- a/frontend/scss/_main.scss +++ /dev/null @@ -1,409 +0,0 @@ -body { - background-color: #eeeeee; - font-family: "Roboto", "Helvetica Neue", Helvetica, Arial; -} - -footer { - background-color: white; - font-size: 0.9em; - padding: 15px; - @include prefixer(border-shadow, 0px 0px 20px 2px #dddddd, webkit moz spec); -} - -section { - clear: both; - - > h1 { - text-align: center; - text-transform: uppercase; - letter-spacing: 2px; - font-size: 1em; - font-weight: 600; - text-shadow: 1px 1px white; - margin-bottom: 0; - } - - > h2 { - color: #999; - text-align: center; - font-size: 0.9em; - font-weight: 600; - text-shadow: 1px 1px white; - margin: 0 auto 25px auto; - width: 60%; - line-height: normal; - - @media only screen and (max-width: 768px) { - width: 90%; - } - } -} - -.mui-appbar { - display: flex; - justify-content: space-between; - - .mui--text-headline { - margin-left: 20px; - float: left; - } - - a { - color: white; - text-decoration: none; - } - - .left { - float: left; - } - - .back { - margin-left: 20px; - font-size: 0.8em; - margin-top: 5px; - } - - .back a { - color: #b3dbfb; - } - - .icons { - display: flex; - align-items: center; - - .icon { - margin-right: 20px; - text-align: center; - } - } -} - -.mui--bg-accent-light { - &.mui-panel { - margin-bottom: 0; - } - - a { - color: #fff; - text-decoration: underline; - } -} - -.mui--bg-accent { - &.mui-panel { - margin-bottom: 0; - } - - a { - color: #fff; - text-decoration: underline; - } -} - -.widget-name { - text-transform: uppercase; - - .tag { - color: rgb(33, 150, 243); - font-size: 0.7em; - font-weight: 500; - padding-left: 2px; - - i.material-icons { - font-size: 1.1em; - } - } -} - -@media only screen and (min-width: 768px) and (max-width: 1180px) { - .widget-name .tag .hide-narrow-panel { - display: none; - } -} - -.mui-table.small { - font-size: 0.8em; - margin-bottom: 0px; -} - -.mui-table.small tbody > tr:hover { - background-color: #f0f0f0; -} - -.chart-labels { - font-size: 0.8em; -} - -.mui-table.small td, -.mui-table.small th { - padding: 0px; -} - -.row { - clear: both; -} - -.margin-top10 { - margin-top: 10px; -} - -.small { - font-size: 0.8em; -} - -.gray { - color: #bbbbbb; -} - -.clear { - clear: both; -} - -.api-link { - float: right; - text-transform: uppercase; - font-size: 0.8em; - background-color: rgb(255, 111, 0); - color: white; - padding: 2px; - font-weight: bold; -} - -.api-link:hover { - color: white; - text-decoration: none; -} - -.api-link:active { - color: white; - text-decoration: none; -} - -.amount-column { - text-align: left; -} - -.account { - margin-left: 2px; -} - -.account-tag { - text-transform: uppercase; - font-size: 0.8em; - background-color: #eeeeee; - padding: 2px; -} - -.slick-slider { - user-select: initial; - -webkit-user-select: initial; - -moz-user-select: initial; - -ms-user-select: initial; -} - -@keyframes anim_pulse { - 0%, - 100% { - transform: scale(0); - } - 50% { - transform: scale(1); - } -} - -.pulse-container { - width: 60px; - height: 60px; - position: relative; - margin-left: auto; - margin-right: auto; - - .pulse { - background-color: rgb(33, 150, 243); - width: 60px; - height: 60px; - border-radius: 100%; - opacity: 0.6; - position: absolute; - top: 0px; - left: 0px; - - &.pulse1 { - animation: anim_pulse 2s ease-in-out 1s infinite both; - } - - &.pulse2 { - animation: anim_pulse 2s ease-in-out 0s infinite both; - } - - &.pulse1.slow { - animation: anim_pulse 4s ease-in-out 2s infinite both; - background-color: orange; - } - - &.pulse2.slow { - animation: anim_pulse 4s ease-in-out 0s infinite both; - background-color: orange; - } - - &.pulse1.very-slow { - animation: anim_pulse 10s ease-in-out 5s infinite both; - background-color: red; - } - - &.pulse2.very-slow { - animation: anim_pulse 10s ease-in-out 0s infinite both; - background-color: red; - } - - &.pulse1.down { - animation: anim_pulse 20s ease-in-out 10s infinite both; - background-color: black; - } - - &.pulse2.down { - animation: anim_pulse 20s ease-in-out 0s infinite both; - background-color: black; - } - } -} - -.axis text { - font-size: 10px; -} - -.arc text { - font-size: 10px; -} - -.node-panel { - padding: 0px !important; - margin-bottom: 5px; - - i.dicon { - position: absolute; - color: #777; - font-size: 18px; - } - - .afterI { - margin-left: 22px; - } - - .uptime-great { - font-weight: bold; - color: #2196f3; - } - - .uptime-good { - color: #2196f3; - } - - .uptime-normal { - color: #ff6f00; - } - - .uptime-bad { - color: #cc0000; - } -} - -.node-panel-large { - display: flex; - align-items: center; - min-height: 30px; -} - -.node-circle-container { - display: flex; - align-items: center; -} - -.node-circle { - width: 10px; - height: 10px; - border-radius: 100%; - padding: 0 !important; - margin-right: 10px; -} - -.node-circle.blue { - background-color: #2196f3; -} - -.node-circle.orange { - background-color: orange; -} - -.node-circle.red { - background-color: red; -} - -.node-barchart-container { - display: flex; - justify-content: flex-end; - align-items: center; - position: relative; -} - -.node-barchart-container .domain { - display: none; - visibility: hidden; -} - -.node-barchart { - padding-top: 5px !important; -} - -.node-hovered-container { - visibility: hidden; - z-index: 10; - width: 100%; - height: 100%; - position: absolute; - left: 0; - transition: all ease-out 100ms; -} - -.node-pubkey { - display: flex; - align-items: center; - justify-content: center; - background-color: white; - width: 85%; - height: 100%; - font-family: monospace; - font-size: 8pt; - box-shadow: 1px 0px 7px 0px white; -} - -.node-panel:hover .node-hovered-container { - visibility: visible; - transition: all ease-in 100ms; -} - -.node-dropdown-button { - cursor: pointer; -} - -.node-dropdown { - padding: 0px 10px; -} - -.node-update-interval-container { - padding-bottom: 5px; -} - -ul.incidents { - margin: 0; - padding: 0; - list-style-type: none; - font-size: 12px; -} - -.mui--text-subhead { - @media screen and (max-width: 500px) { - font-size: 14px; - line-height: 20px; - } -} diff --git a/frontend/scss/_tokens.scss b/frontend/scss/_tokens.scss new file mode 100644 index 00000000..e686ce75 --- /dev/null +++ b/frontend/scss/_tokens.scss @@ -0,0 +1,72 @@ +// Design tokens — Stellar Brand Guidelines (stellar.org/brand-resources) +// Primary: gold #FDDA24 · black #0F0F0F · white #F6F7F8 +// Secondary: warm gray #D6D2C4 · lavender #B7ACE8 · teal #00A7B5 · navy #002E5D + +:root { + // surfaces + --color-bg: #0f0f0f; + --color-surface: #161616; + --color-surface-raised: #1c1c1c; + --color-border: #262626; + --color-border-strong: #333333; + + // text + --color-text: #f6f7f8; + --color-text-secondary: #a8a8a8; + --color-text-faint: #6e6e6e; + + // brand + --color-accent: #fdda24; + --color-accent-contrast: #0f0f0f; + --color-lavender: #b7ace8; + --color-teal: #00a7b5; + --color-warm: #d6d2c4; + --color-navy: #002e5d; + + // semantic + --color-danger: #e5484d; + --color-warning: #ff9f0a; + --color-link: #fdda24; + + // charts — categorical pair validated for CVD/contrast on both surfaces; + // gold is reserved for single-series marks so it stays scarce + --chart-primary: #fdda24; + --chart-series-a: #00a7b5; + --chart-series-b: #8f7fd6; + --chart-muted: rgba(214, 210, 196, 0.55); + --chart-grid: rgba(246, 247, 248, 0.07); + --chart-axis-text: #6e6e6e; + + // typography + --font-serif: "Lora", georgia, serif; + --font-sans: "Inter", -apple-system, blinkmacsystemfont, "Helvetica Neue", + sans-serif; + --font-mono: ui-monospace, "SF Mono", "Menlo", monospace; + + // shape & motion + --radius-card: 12px; + --radius-chip: 6px; + --shadow-card: none; + --transition-fast: 120ms ease-out; +} + +:root[data-theme="light"] { + --color-bg: #f6f7f8; + --color-surface: #ffffff; + --color-surface-raised: #ffffff; + --color-border: #e4e4e4; + --color-border-strong: #cfcfcf; + + --color-text: #0f0f0f; + --color-text-secondary: #555555; + --color-text-faint: #8a8a8a; + + // gold fails WCAG as text on light backgrounds — use navy for links there + --color-link: #002e5d; + + // brand gold fails 3:1 on white — validated darker step for light charts + --chart-primary: #b8860b; + --chart-grid: rgba(15, 15, 15, 0.07); + --chart-axis-text: #8a8a8a; + --shadow-card: 0 1px 2px rgba(15, 15, 15, 0.06); +} diff --git a/frontend/scss/index.scss b/frontend/scss/index.scss index 3619c6bc..bcbea8d2 100644 --- a/frontend/scss/index.scss +++ b/frontend/scss/index.scss @@ -1,3 +1,6 @@ @import "bourbon"; -@import "_main"; +@import "_tokens"; +@import "_base"; +@import "_components"; +@import "_charts"; @import "_force"; diff --git a/index.html b/index.html index 0036dd93..b37ec713 100644 --- a/index.html +++ b/index.html @@ -10,7 +10,7 @@ /> - + - + - + - - - - + - - - - - + + + +
    diff --git a/package-lock.json b/package-lock.json index a1bb2748..65914894 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,9 @@ "name": "stellar-dashboard", "version": "1.0.0", "dependencies": { + "@fontsource/inter": "^5.2.8", + "@fontsource/lora": "^5.2.8", + "@fontsource/press-start-2p": "^5.2.7", "@google-cloud/bigquery": "^5.10.0", "@stellar/prettier-config": "^1.0.1", "@stellar/stellar-sdk": "^14.4.3", @@ -25,7 +28,6 @@ "lodash": "^4.17.21", "moment": "^2.17.1", "morgan": "^1.8.2", - "muicss": "^0.9.5", "react": "^18.2.0", "react-dom": "^18.2.0", "redis": "^4.0.1" @@ -839,6 +841,33 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/@fontsource/inter": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.2.8.tgz", + "integrity": "sha512-P6r5WnJoKiNVV+zvW2xM13gNdFhAEpQ9dQJHt3naLvfg+LkF2ldgSLiF4T41lf1SQCM9QmkqPTn4TH568IRagg==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/lora": { + "version": "5.2.8", + "resolved": "https://registry.npmjs.org/@fontsource/lora/-/lora-5.2.8.tgz", + "integrity": "sha512-AQlfsHw4TP1x/eb2IZ6VjQ70ctKa39m9JN9A4zlvDOeKYLrCs+GaYIEQ86Y6YfSPGHn01bErXkRcyktOW0LOPQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, + "node_modules/@fontsource/press-start-2p": { + "version": "5.2.7", + "resolved": "https://registry.npmjs.org/@fontsource/press-start-2p/-/press-start-2p-5.2.7.tgz", + "integrity": "sha512-ezSxYRchANoteja170ZHh2Tt4oejmVEvX42VFPOVVvERSZ68pcuZFjbhHfrK/sNjYmaGg656y7B3431ojO9PCQ==", + "license": "OFL-1.1", + "funding": { + "url": "https://github.com/sponsors/ayuhito" + } + }, "node_modules/@google-cloud/bigquery": { "version": "5.10.0", "resolved": "https://registry.npmjs.org/@google-cloud/bigquery/-/bigquery-5.10.0.tgz", @@ -6774,18 +6803,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/muicss": { - "version": "0.9.43", - "resolved": "https://registry.npmjs.org/muicss/-/muicss-0.9.43.tgz", - "integrity": "sha512-8y3zx+QFA5TR/+Uo0/bV+iqs6rZvY50O3NAuyRBAvhz6tPoR+SmUVWK43iMRlTnOPsNd2KPYJSyN7K/lc7B+mw==", - "license": "MIT", - "dependencies": { - "react-addons-shallow-compare": "^15.6.2" - }, - "peerDependencies": { - "react": "^0.14.0 || ^15.0.0 || ^16.0.0" - } - }, "node_modules/nano-spawn": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/nano-spawn/-/nano-spawn-2.0.0.tgz", @@ -7598,15 +7615,6 @@ "node": ">=0.10.0" } }, - "node_modules/react-addons-shallow-compare": { - "version": "15.6.3", - "resolved": "https://registry.npmjs.org/react-addons-shallow-compare/-/react-addons-shallow-compare-15.6.3.tgz", - "integrity": "sha512-EDJbgKTtGRLhr3wiGDXK/+AEJ59yqGS+tKE6mue0aNXT6ZMR7VJbbzIiT6akotmHg1BLj46ElJSb+NBMp80XBg==", - "license": "MIT", - "dependencies": { - "object-assign": "^4.1.0" - } - }, "node_modules/react-dom": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", diff --git a/package.json b/package.json index 87ccdb4b..6cf88dea 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,9 @@ }, "author": "Stellar.org", "dependencies": { + "@fontsource/inter": "^5.2.8", + "@fontsource/lora": "^5.2.8", + "@fontsource/press-start-2p": "^5.2.7", "@google-cloud/bigquery": "^5.10.0", "@stellar/prettier-config": "^1.0.1", "@stellar/stellar-sdk": "^14.4.3", @@ -48,7 +51,6 @@ "lodash": "^4.17.21", "moment": "^2.17.1", "morgan": "^1.8.2", - "muicss": "^0.9.5", "react": "^18.2.0", "react-dom": "^18.2.0", "redis": "^4.0.1" diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 00000000..fcfd3ad6 Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/og.png b/public/og.png new file mode 100644 index 00000000..503d196b Binary files /dev/null and b/public/og.png differ diff --git a/vite.config.js b/vite.config.js index c49a420d..e6e23345 100644 --- a/vite.config.js +++ b/vite.config.js @@ -21,11 +21,23 @@ export default defineConfig({ }), ], + build: { + rollupOptions: { + output: { + manualChunks: { + react: ["react", "react-dom"], + "stellar-sdk": ["@stellar/stellar-sdk"], + d3: ["d3"], + }, + }, + }, + }, + // API proxy to backend server: { proxy: { "/api": { - target: "http://localhost:5000", + target: process.env.API_PROXY_TARGET || "http://localhost:5000", changeOrigin: true, }, },