From 46ee13db663ece0bb9c400a237425f1e63ed130b Mon Sep 17 00:00:00 2001 From: Miguel Nieto <39246879+miguelnietoa@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:43:04 -0500 Subject: [PATCH 01/34] Add official Stellar logo asset and configurable dev proxy target --- frontend/assets/stellar-logo-white.svg | 12 ++++++++++++ vite.config.js | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) create mode 100644 frontend/assets/stellar-logo-white.svg 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/vite.config.js b/vite.config.js index c49a420d..2ca2b518 100644 --- a/vite.config.js +++ b/vite.config.js @@ -25,7 +25,7 @@ export default defineConfig({ server: { proxy: { "/api": { - target: "http://localhost:5000", + target: process.env.API_PROXY_TARGET || "http://localhost:5000", changeOrigin: true, }, }, From 12098c95715173f274e15469189c59c23fb8cf3c Mon Sep 17 00:00:00 2001 From: Miguel Nieto <39246879+miguelnietoa@users.noreply.github.com> Date: Wed, 8 Jul 2026 19:46:14 -0500 Subject: [PATCH 02/34] Add brand design tokens, base styles, and Card component --- frontend/components/ui/Card.jsx | 29 ++ frontend/main.jsx | 7 + frontend/scss/_base.scss | 166 +++++++++++ frontend/scss/_charts.scss | 38 +++ frontend/scss/_components.scss | 470 ++++++++++++++++++++++++++++++++ frontend/scss/_tokens.scss | 82 ++++++ frontend/scss/index.scss | 4 + index.html | 37 +-- package-lock.json | 20 ++ package.json | 2 + 10 files changed, 838 insertions(+), 17 deletions(-) create mode 100644 frontend/components/ui/Card.jsx create mode 100644 frontend/scss/_base.scss create mode 100644 frontend/scss/_charts.scss create mode 100644 frontend/scss/_components.scss create mode 100644 frontend/scss/_tokens.scss 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/main.jsx b/frontend/main.jsx index 0f971c0e..9c8c83e6 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 2026) +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..250d9495 --- /dev/null +++ b/frontend/scss/_base.scss @@ -0,0 +1,166 @@ +// 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; + + @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; +} + +// --------------------------------------------------------------------------- +// Brand texture (Brand Guidelines 2026: fine-dotted textures) + +.dot-texture { + background-image: radial-gradient( + rgba(183, 172, 232, 0.25) 1px, + transparent 1px + ); + background-size: 12px 12px; +} + +:root[data-theme="light"] .dot-texture { + background-image: radial-gradient( + rgba(15, 15, 15, 0.12) 1px, + transparent 1px + ); +} 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..f341b689 --- /dev/null +++ b/frontend/scss/_components.scss @@ -0,0 +1,470 @@ +// 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); + background-image: var(--header-glow); + 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; + } +} + +// 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; + } +} + +// --------------------------------------------------------------------------- +// 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); + + &: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; + } +} + +// --------------------------------------------------------------------------- +// 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); + } + + &.secondary { + background: var(--chart-secondary); + } + + &.tertiary { + background: var(--chart-tertiary); + } +} + +// --------------------------------------------------------------------------- +// 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; +} + +// --------------------------------------------------------------------------- +// 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; + } +} + +// --------------------------------------------------------------------------- +// 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; + } +} + +// --------------------------------------------------------------------------- +// 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/_tokens.scss b/frontend/scss/_tokens.scss new file mode 100644 index 00000000..5ad0dc0b --- /dev/null +++ b/frontend/scss/_tokens.scss @@ -0,0 +1,82 @@ +// Design tokens — Stellar Brand Guidelines 2026 +// 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 + --chart-primary: #fdda24; + --chart-secondary: #b7ace8; + --chart-tertiary: #00a7b5; + --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; + + // header glow (brand "fluid gradient" language) + --header-glow: radial-gradient( + 60% 220px at 50% 0, + rgba(253, 218, 36, 0.09), + transparent + ); +} + +: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; + + --chart-grid: rgba(15, 15, 15, 0.07); + --chart-axis-text: #8a8a8a; + --shadow-card: 0 1px 2px rgba(15, 15, 15, 0.06); + + --header-glow: radial-gradient( + 60% 220px at 50% 0, + rgba(253, 218, 36, 0.18), + transparent + ); +} diff --git a/frontend/scss/index.scss b/frontend/scss/index.scss index 3619c6bc..016d270f 100644 --- a/frontend/scss/index.scss +++ b/frontend/scss/index.scss @@ -1,3 +1,7 @@ @import "bourbon"; +@import "_tokens"; +@import "_base"; +@import "_components"; +@import "_charts"; @import "_main"; @import "_force"; diff --git a/index.html b/index.html index 0036dd93..224c02df 100644 --- a/index.html +++ b/index.html @@ -54,23 +54,26 @@ content="https://www.stellar.org/developers/images/favicon/rocket-144x144.png" /> - - - - - + + + +
diff --git a/package-lock.json b/package-lock.json index a1bb2748..3fb778c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "stellar-dashboard", "version": "1.0.0", "dependencies": { + "@fontsource/inter": "^5.2.8", + "@fontsource/lora": "^5.2.8", "@google-cloud/bigquery": "^5.10.0", "@stellar/prettier-config": "^1.0.1", "@stellar/stellar-sdk": "^14.4.3", @@ -839,6 +841,24 @@ "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/@google-cloud/bigquery": { "version": "5.10.0", "resolved": "https://registry.npmjs.org/@google-cloud/bigquery/-/bigquery-5.10.0.tgz", diff --git a/package.json b/package.json index 87ccdb4b..9d61fcab 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,8 @@ }, "author": "Stellar.org", "dependencies": { + "@fontsource/inter": "^5.2.8", + "@fontsource/lora": "^5.2.8", "@google-cloud/bigquery": "^5.10.0", "@stellar/prettier-config": "^1.0.1", "@stellar/stellar-sdk": "^14.4.3", From 6830a9bf0197423c2e3bc38b1621e96e51c06e00 Mon Sep 17 00:00:00 2001 From: Miguel Nieto <39246879+miguelnietoa@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:03:11 -0500 Subject: [PATCH 03/34] Rebuild page layout and header, remove muicss --- frontend/components/App.jsx | 283 ++++++++++++++++++--------------- frontend/components/AppBar.jsx | 76 ++++----- frontend/scss/_base.scss | 6 + frontend/scss/_force.scss | 157 ++++++++---------- frontend/scss/_main.scss | 264 +----------------------------- package-lock.json | 23 --- package.json | 1 - 7 files changed, 264 insertions(+), 546 deletions(-) diff --git a/frontend/components/App.jsx b/frontend/components/App.jsx index 689f70ed..b481f17d 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"; @@ -173,53 +172,51 @@ export default class App extends React.Component { turnOffForceTheme={this.turnOffForceTheme.bind(this)} /> - { - /* Incidents */ - this.state.statusPage - ? this.state.statusPage.incidents.map((m) => { - 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.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)} +
{sanitizeHtml(m.incident_updates[0].body)}
) : null}
- - ); - }) - : null - } - { - /* Scheduled maintenances */ - this.state.statusPage && - this.state.statusPage.scheduled_maintenances.length ? ( - - ) : null - } - {this.chrome57 ? ( - -
+ ); + }) + : 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 {" "} @@ -227,25 +224,24 @@ export default class App extends React.Component { Please switch to any other browser or wait for a fix by a Chromium team.
- - ) : null} - {this.state.sleeping ? ( - -
+ ) : 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

-
-
+ ) : null} + {this.state.forceTheme && this.state.may4 ? ( +

+ May the 4th be with you! +

+ ) : null} +
+ +
+
+

Live network

+
+
-
+
- +
-
-

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 Stellarbeat and visualize consensus. + + + Explore nodes → + +
+
-
-

Test network status

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

Test network

+
+
+ + + +
+
+ + + +
-
+
+ +
+
+ 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..d4f1801b 100644 --- a/frontend/components/AppBar.jsx +++ b/frontend/components/AppBar.jsx @@ -1,47 +1,51 @@ import React from "react"; +import logoUrl from "../assets/stellar-logo-white.svg"; export default class AppBar extends React.Component { render() { return ( -
- + ); } } diff --git a/frontend/scss/_base.scss b/frontend/scss/_base.scss index 250d9495..5a80533c 100644 --- a/frontend/scss/_base.scss +++ b/frontend/scss/_base.scss @@ -147,6 +147,12 @@ th, margin-bottom: 24px; } +.section-footnote { + color: var(--color-text-faint); + font-size: 12px; + margin: 16px 0 0; +} + // --------------------------------------------------------------------------- // Brand texture (Brand Guidelines 2026: fine-dotted textures) diff --git a/frontend/scss/_force.scss b/frontend/scss/_force.scss index adee0466..322a2ff2 100644 --- a/frontend/scss/_force.scss +++ b/frontend/scss/_force.scss @@ -1,73 +1,70 @@ -@import url('https://fonts.googleapis.com/css?family=Press+Start+2P'); +@import url("https://fonts.googleapis.com/css?family=Press+Start+2P"); +// 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; + --header-glow: radial-gradient( + 60% 220px at 50% 0, + rgba(178, 0, 20, 0.25), + transparent + ); + + .site-header { + background-color: #7e0010; + border-bottom-color: #a00016; + + .site-nav a, + .site-brand-suffix { + color: white; + } } - a { - color: #b20014; + .section-title::before, + .capacity-meter-fill { + background: #b20014; } - .mui-appbar { - background-color: #7E0010; - - .icons i { - color: white; - } + .card-api-link { + border-color: #b20014; + color: #ff2237; - .icons a { + &:hover { + background-color: #b20014; color: white; } } - .mui-panel { - background-color: #222; - color: white; - } - .account-tag { background-color: black; } - .tag { - color: #b20014; - } - - .api-link { - background-color: #7E0010; - color: white; - } - - .recharts-wrapper { - background-color: white; - } - - footer { - 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 +73,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 index 7ff01c42..11a529df 100644 --- a/frontend/scss/_main.scss +++ b/frontend/scss/_main.scss @@ -1,148 +1,3 @@ -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; } @@ -152,130 +7,13 @@ section { } .gray { - color: #bbbbbb; + color: var(--color-text-faint); } .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; diff --git a/package-lock.json b/package-lock.json index 3fb778c4..9ed295fb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,7 +27,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" @@ -6794,18 +6793,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", @@ -7432,7 +7419,6 @@ "version": "2.8.8", "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", - "dev": true, "license": "MIT", "bin": { "prettier": "bin-prettier.js" @@ -7618,15 +7604,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 9d61fcab..de862b4d 100644 --- a/package.json +++ b/package.json @@ -50,7 +50,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" From 034fbbbb7ebaffc2223aa304e04443dd461df2f6 Mon Sep 17 00:00:00 2001 From: Miguel Nieto <39246879+miguelnietoa@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:03:22 -0500 Subject: [PATCH 04/34] Restyle status, incidents, fees, operations, and supply widgets --- frontend/components/AccountBalance.jsx | 18 +- frontend/components/AmountWidget.jsx | 50 +++-- frontend/components/FeeStats.jsx | 154 ++++++------- frontend/components/Incidents.jsx | 27 ++- frontend/components/ListAccounts.jsx | 27 +-- frontend/components/LumensCirculating.jsx | 17 +- frontend/components/LumensDistributed.jsx | 19 +- frontend/components/LumensNonCirculating.jsx | 16 +- frontend/components/NetworkStatus.jsx | 97 ++++---- frontend/components/RecentOperations.jsx | 94 ++++---- frontend/components/ScheduledMaintenance.jsx | 21 +- frontend/components/TotalCoins.jsx | 15 +- frontend/scss/_components.scss | 220 ++++++++++++++++++- 13 files changed, 479 insertions(+), 296 deletions(-) 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/FeeStats.jsx b/frontend/components/FeeStats.jsx index cb3302bc..b69b9474 100644 --- a/frontend/components/FeeStats.jsx +++ b/frontend/components/FeeStats.jsx @@ -1,15 +1,7 @@ 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) { @@ -17,21 +9,21 @@ export default class FeeStats extends React.Component { this.state = { loading: true, 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,63 @@ 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() { - 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); - - if (field.id === "ledger_capacity_usage") { - styleFn = this.capacityStyle; - displayVal = `${Math.round(val * 100)}%`; - } + const capacity = get(this.state.stats, "ledger_capacity_usage"); - return ( - - - - - ); - }) - )} - -
MetricValue
Loading...
{field.name}{displayVal}
-
+ return ( + + {this.state.loading ? ( +
+ + + +
+ ) : ( +
+
+
+ Capacity usage + + {Math.round(capacity * 100)}% + +
+
+
+
+
+ + + {this.nameMap + .filter((field) => field.id !== "ledger_capacity_usage") + .map((field) => ( + + + + + ))} + +
{field.name} + {get(this.state.stats, field.id)} +
+
+ )} +
); } } 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/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..a98456b6 100644 --- a/frontend/components/NetworkStatus.jsx +++ b/frontend/components/NetworkStatus.jsx @@ -1,8 +1,8 @@ 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 Card from "./ui/Card.jsx"; // ledgersInAverageCalculation defines how many last ledgers should be // considered when calculating average ledger length. @@ -99,77 +99,74 @@ export default class NetworkStatus extends React.Component { } 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"; } } return ( - -
    Network Status: {this.props.network}
    -
    - {/* Fancy pulse effect */} -
    -
    -
    -
    + +
    +
    +
    {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. + {!this.state.loading ? ( +
    +
    +
    Last ledger
    +
    + #{this.state.lastLedgerSequence} +
    - ) : ( - "" - )} -
    - +
    +
    Protocol
    +
    {this.state.protocolVersion}
    +
    +
    +
    Closed
    +
    + ~{ago(this.state.closedAt)} ago in{" "} + {this.state.lastLedgerLength / 1000}s +
    +
    +
    +
    + Avg close · {ledgersInAverageCalculation} ledgers +
    +
    + {round(averageLedgerLength, 2)}s +
    +
    +
    + ) : ( +
    + + + + +
    + )} + ); } } 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/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/scss/_components.scss b/frontend/scss/_components.scss index f341b689..4f7de34e 100644 --- a/frontend/scss/_components.scss +++ b/frontend/scss/_components.scss @@ -174,6 +174,25 @@ } } +.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 @@ -215,12 +234,12 @@ background: var(--chart-primary); } - &.secondary { - background: var(--chart-secondary); + &.series-a { + background: var(--chart-series-a); } - &.tertiary { - background: var(--chart-tertiary); + &.series-b { + background: var(--chart-series-b); } } @@ -268,6 +287,112 @@ 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-tag { + display: inline-block; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + padding: 1px 5px; + margin-left: 4px; + border-radius: var(--radius-chip); + background: var(--color-border); + color: var(--color-text-secondary); +} + +.account img { + height: 12px; + margin-left: 4px; + vertical-align: -1px; +} + // --------------------------------------------------------------------------- // Banners (active incidents / maintenance) @@ -396,6 +521,86 @@ } } +// --------------------------------------------------------------------------- +// 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); +} + // --------------------------------------------------------------------------- // Hero numbers (lumen supply) @@ -421,6 +626,13 @@ } } +.amount-precise { + margin-top: 4px; + color: var(--color-text-faint); + font-size: 12px; + font-variant-numeric: tabular-nums; +} + // --------------------------------------------------------------------------- // Skeletons From 62d5700841fcc6f6caf31bdbd6fa9cd2a988a22a Mon Sep 17 00:00:00 2001 From: Miguel Nieto <39246879+miguelnietoa@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:03:23 -0500 Subject: [PATCH 05/34] Restyle D3 charts with validated brand palette --- frontend/components/D3BarChart.jsx | 208 +++++++------- frontend/components/D3BarChartNoXLabels.jsx | 272 +++++++++--------- .../components/FailedTransactionsChart.jsx | 59 ++-- frontend/components/LedgerCloseChart.jsx | 45 ++- .../PublicNetworkLedgersHistoryChart.jsx | 48 ++-- frontend/components/TransactionsChart.jsx | 53 ++-- frontend/components/ui/chartUtils.js | 77 +++++ frontend/scss/_tokens.scss | 7 +- 8 files changed, 403 insertions(+), 366 deletions(-) create mode 100644 frontend/components/ui/chartUtils.js diff --git a/frontend/components/D3BarChart.jsx b/frontend/components/D3BarChart.jsx index 56fdbe22..870284b7 100644 --- a/frontend/components/D3BarChart.jsx +++ b/frontend/components/D3BarChart.jsx @@ -1,150 +1,144 @@ -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]); + 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..42ee5ec9 100644 --- a/frontend/components/D3BarChartNoXLabels.jsx +++ b/frontend/components/D3BarChartNoXLabels.jsx @@ -1,206 +1,206 @@ -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, }) { 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); - // 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, + themeTick, ]); return ; diff --git a/frontend/components/FailedTransactionsChart.jsx b/frontend/components/FailedTransactionsChart.jsx index 34fc5dbc..366335d7 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,6 +18,7 @@ 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() { @@ -43,7 +29,7 @@ export default class FailedTransactionsChart extends React.Component { } updateSize() { - let value = this.panel.offsetWidth - 20; + let value = this.panel.offsetWidth - 42; if (this.state.chartWidth != value) { this.setState({ chartWidth: value }); } @@ -126,11 +112,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: [], }, ]; @@ -164,33 +150,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/LedgerCloseChart.jsx b/frontend/components/LedgerCloseChart.jsx index f2ea24f0..7d832c38 100644 --- a/frontend/components/LedgerCloseChart.jsx +++ b/frontend/components/LedgerCloseChart.jsx @@ -1,36 +1,22 @@ 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() { @@ -41,7 +27,7 @@ export default class LedgerChartClose extends React.Component { } updateSize() { - let value = this.panel.offsetWidth - 20; + let value = this.panel.offsetWidth - 42; if (this.state.chartWidth != value) { this.setState({ chartWidth: value }); } @@ -51,7 +37,7 @@ export default class LedgerChartClose extends React.Component { axios.get(this.url).then((response) => { let data = [ { - label: "Ledger Close", + label: "Close time", values: [], }, ]; @@ -98,25 +84,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/PublicNetworkLedgersHistoryChart.jsx b/frontend/components/PublicNetworkLedgersHistoryChart.jsx index d20c02c5..f574a0d3 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, @@ -41,7 +25,7 @@ export default class PublicNetworkLedgersHistoryChart extends React.Component { } updateSize() { - let value = this.panel.offsetWidth - 20; + let value = this.panel.offsetWidth - 42; if (this.state.chartWidth != value) { this.setState({ chartWidth: value }); } @@ -74,19 +58,29 @@ 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/TransactionsChart.jsx b/frontend/components/TransactionsChart.jsx index e6a2b3a8..d0614421 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,6 +18,7 @@ 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() { @@ -43,7 +29,7 @@ export default class TransactionsChart extends React.Component { } updateSize() { - let value = this.panel.offsetWidth - 20; + let value = this.panel.offsetWidth - 42; if (this.state.chartWidth != value) { this.setState({ chartWidth: value }); } @@ -164,31 +150,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/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/scss/_tokens.scss b/frontend/scss/_tokens.scss index 5ad0dc0b..c516b58d 100644 --- a/frontend/scss/_tokens.scss +++ b/frontend/scss/_tokens.scss @@ -28,10 +28,11 @@ --color-warning: #ff9f0a; --color-link: #fdda24; - // charts + // 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-secondary: #b7ace8; - --chart-tertiary: #00a7b5; + --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; From f08b76eb0fa488eb01743a4750c99445f5d6cbab Mon Sep 17 00:00:00 2001 From: Miguel Nieto <39246879+miguelnietoa@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:08:11 -0500 Subject: [PATCH 06/34] Add theme toggle, brand favicon, OG image, and a11y polish --- frontend/assets/favicon.svg | 5 +++ frontend/components/AppBar.jsx | 45 ++++++++++++++++++++ frontend/components/D3BarChart.jsx | 9 +++- frontend/components/D3BarChartNoXLabels.jsx | 13 +++++- frontend/scss/_tokens.scss | 2 + index.html | 34 ++++----------- public/og.png | Bin 0 -> 87214 bytes 7 files changed, 81 insertions(+), 27 deletions(-) create mode 100644 frontend/assets/favicon.svg create mode 100644 public/og.png 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/components/AppBar.jsx b/frontend/components/AppBar.jsx index d4f1801b..97cc8fad 100644 --- a/frontend/components/AppBar.jsx +++ b/frontend/components/AppBar.jsx @@ -1,7 +1,40 @@ 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 (
@@ -43,6 +76,18 @@ export default class AppBar extends React.Component { Turn off the Force theme ) : null} +
diff --git a/frontend/components/D3BarChart.jsx b/frontend/components/D3BarChart.jsx index 870284b7..b753b1b8 100644 --- a/frontend/components/D3BarChart.jsx +++ b/frontend/components/D3BarChart.jsx @@ -140,5 +140,12 @@ export default function D3BarChart({ themeTick, ]); - return ; + return ( + + ); } diff --git a/frontend/components/D3BarChartNoXLabels.jsx b/frontend/components/D3BarChartNoXLabels.jsx index 42ee5ec9..36c4ea2e 100644 --- a/frontend/components/D3BarChartNoXLabels.jsx +++ b/frontend/components/D3BarChartNoXLabels.jsx @@ -203,5 +203,16 @@ export default function D3BarChartNoXLabels({ themeTick, ]); - return ; + return ( + s.label).join(" and ")} bar chart` + : "chart" + } + > + ); } diff --git a/frontend/scss/_tokens.scss b/frontend/scss/_tokens.scss index c516b58d..8ba4790e 100644 --- a/frontend/scss/_tokens.scss +++ b/frontend/scss/_tokens.scss @@ -71,6 +71,8 @@ // 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/index.html b/index.html index 224c02df..06f7e313 100644 --- a/index.html +++ b/index.html @@ -10,7 +10,7 @@ /> - + - + - + - - - - + diff --git a/public/og.png b/public/og.png new file mode 100644 index 0000000000000000000000000000000000000000..898892cdfa137561f11647b243b48527efe99c0f GIT binary patch literal 87214 zcmXuKRa9FI*R|cdw6r)B_u>}Zy|}vug1eXCn&QRXt-;-0ixb>kiaQi9{^$9>@$SQn zeUxQ$UTf`eRb?48WFq8u@7|%w$x5ofd-tK{-MjbhpWnlO`AvC>^6nkxJ2^=)P0y^; z>`y=D`tOJ67LSIeGjC#U3~_KI3-CXxsnwcZB=SVc4kQ*^U>fCbTXt3SCC`){VW>=OMz;Tm}9BEdIQ@OX~JPb zb9>}n0JREH2zwP0Rq8JsMbm__-ER!kaw!%J|H6#Y1jyR7QO9GRdW~O;pl#wx;+b7$ zG^59iX5r5Ty3+yLw?=&~=mS9v;?CUC!o3AS;96)4o!^pVl@$5ihbP)w)f8QYCIi~u z=kdDQvmEz__Bh-C(BxyX6nRr>9&4?|n)x@>7@e34k5rSdkQ^6PZGs(j0w^FwnN(T< zuUaA+P}{N9m@S|4S3f_>tqDqu$~td}9Z>Oe&h>k(%={l&$HRkl7od=>$g58b52PB! zGNaHADE_fD#`XYL9wd8F13o+VxFvzD|J?dkHQ+m$juRumGA1>^Uk33-k7kx$l$LCG zswrX(pPjV6H<`s=P}<^yNC4F-a>Ys%jMiN-=8|+oQ_qU6F**_wDLv*3 zVBNc#1m;H&vrwkRDwy1vEWV6DA59L(Q`Qcd)d}2dE54n+PWK#*O<@B~E%BP)4|RfM z7*|K!7)CEYTHcplj^`_$a}OEBs?k!ZTlY{#!8#RMS1jJ+qg~Kf0q{QTc~5l}jh8(1 z$yo$vcT44czsUZ{*k?f~Tm~*@!7~WX-LSr)NiLzkq0xFpG?P`W8LXO)QW!K#ay~Sr zc5Y?E%4E_>kV;W@{_ZUKuxe?(t>z3E==o8iShp8fto$=G8gDDOESb0u$-_L+Db-Lc zU%7&FVtiWwZA{25WquzB&W|KD-|Z3J{#Ny&+Sv0U)50vCzQTV`(;^i-pOgL#G(0Q^ znhnt03Ce$3cd(v`&ijL_sg-5)In|T|gO>MX%26;&&U;I`aZ0AR8`zo^8q*nKve0BB z8czxsvfd-Q(W-T_d+gA z{=3iFHCJe*9z{I1Id@;`*wdRifRZnT%n%VtMe>p*grhU_^GIp?d{vm~>H7yinA~eb znCdB)ILNJ4#4*I&Pk9zEV~Q{aTW_Q;=|oRb7u*?d%AgbRD<@lFc65FuLb1x+X9cB| z1qW3Y3sM_&Ib{>nV(x;F7Ivp?@U^fx_5eumZla>|`%bUZFUP2>3JitNFi;HcmaH@V zq6>1G8Mwq3Ok(-A%9QAfqFajgEiCm>G-^y;fd(g%tQZ9noPGO>^xVz)-? zhxP)}yc)h46O0J70w`PC2HE^NR9{xh-p1W#!`2&Hm~Q76rGkT=21cvwD0SR{&09!w zqZWpv$;`^0)Qda*7T|b=|AZKqTxloBa>L^2YC4@Or#1;v@Y^Rg^{QApe1({c5c0k> zuS!_kqw-;pKA-{2`4fp#Jj7RlXacT(F>*xs+~w4cbg(w4s>}^$L{6b>LB0G9UL;@AulP6Y(`EIhNrb;i@Tcp0&53G-bdF!xiA@W{oLbK;xtd-@ej zOe(+fVa>oy#OXg01P!z$p-;e=KAzPs>bOs#84sCHspJ$RN46MBi^(spYC; z(xO!v$N%;G9#a6FCYfm88MeKTl|XFbHG{q?{u!yWUsk&xFAAYHW|TM{r%xhh#LZjk zLmjBRhlUEFLa?HOXVSvvmF=V%Yu70^Kn843exzQtP9{F&Y=!OYBqL>1 zlH)6=|MfdDcYUW3?e0%Ct zFUG}=%@Mssddy>dlzz!UXV!bnvLWOk;b3pvsM`4 zJmu~%uS`R?A14?I@6dxX78ZXgwYPVD^RY>L83_QTuCGO}u|0kNwL}fvi%5#?0MQzXxOYBu(YX58Ti)Lr-ttdICCj4+4_#s!K=gae2*2NIuVwyK{qrv% zzwTf}EDFc45VAYI7QVb^gOTKFkQ4Oaqs#y5nEBi=HVH+LpOauNDS{`0_>2N)sJ2F& z$;rf8nb`l;(k$}MBP?=$bh{-wbF|C;sYYb(ltMF=qmD4o+;5`Ij|zV)d+n!fPdW%g zatW5qWswBrDv%dw9kXk#dP+{E3~nEv5G+=z)|gmL^&-$SP$zh3Pi5lQ1APC&TM2fG zmP1h^`rpe5i8xrJXfo{z+&pb_J!au*gFW_?Y2Uy%66fN1Tl{vU9ba~}t6I^qw8HDJ zywy(bz!t@~Bpd&ZP%MJvx(_zQsV;)gOan$$o8vWB^k3bQ`BxTj@^y;VP{O3AY zuuhIUOv~}5LtuAZ2fg}D&L;@LiUhrB2~@-0Df9ES$5$Oe7AZNrSfUA7C$@1-7#l$| zmv|JSYWwBboX?-Z&q#ET`YGMKzd`t4r^oVXx*FDVu!W;bm^;7>m+OO{FYEm zirg%g{MJSr&-P+)>r#o;+Tn=?B(Fd08>BM#bP|{!2w`V@KWUGy;^mIU2ErQ}@b5i4O{* z^~ab{XWO|GgjW|2zB%^S@g28JIX5~zp@MAgaK5!fJLr!a{;9&SdRaPb1n~NN?&Ej| za>tg07}9~%!ytDEJ>7bRY=|XQ1+{`Tx(Y4D^R5nTPE|RoHz%S z7aY=Un^Mv_*H2G)R{Xt-w~V~(kCZ}=IdYcElqPK^$_H^3pDbXMiweqa!?VB~cw$@{ zJCG+W8R^;v3nOUGJvzF*M^C0dsbZEX3(B{*-xHYaX{^hLz`Rhp%t6zRvyA*u-?qst zu?lPco0b(s40EO*=J0Ke57{!AA&A)qCsk_Aj^)kf8qn_={%`nytFpqq_%-Td0U{jM z@;G1{L~iEDpH7{lR2;-cX%B5BfaKMF`x?sQMCcYVFtD9Q;{J-htM?CqbQmnehiGmP zc!4=G?G+G)Q2lEu+^wG3avkbrs4V0bQ$D^YrR@Bb_vEog4swSXiPXURkf~J=qBErd zG@$_l@#vOYRAYv+F6bJf1liIId`{Ox1N#9tnBcls&jM(BXCP0aKX2Vw#|LFPY#zUv zy)Ln#5Vl?m@DM(+#jXG(72Jq>5e~p^)mA#IrB5|rheHCD5_h`z&(=&|6!cH`$gp z{iiMoOaW8j!UY(PHd+|P?5kl6D0ms^Vz*7U>T!T4A}mQ`fX1wl6mPIJRk3b-$e|<= zR}YiD@otQ$PYN^>qd*IzlHd^9m3;YJ$d{D)k*c8Fv7~*@2eg6LbT;tz1+sAC43uRx zJYktTxgf0@KsspiqMS}fpQu8|<^^}!V{5cR$$!)g2{ZlIgQe@f|KCq)RJ+>Ww@Q>K z&PB2M&l7DnOp7fSlN&cI3ay6j#xAXO^;gpLO04i^tHUE??%~s#d_vzkt55MBOV32e z)^YRWto1#n-dc-tE$M%hW19hM!yO?p7MIqNIe6CrtOWlXqQ>unNiFMzgQZCq!STJ2 zLV|9}{};F?+GsXNeMG~V2pJ4q5vS2=VtB0X*l%0L$uZ2;U`$c_w@byE>NlV3AwLct zQOCWZrGxd>i_6=BYQ1-p4gTyMcaC|c`30)}8CBQsLs5>i}GK!|QHiYDEA+?Vb^Fo;L5K`Bd$RTrAc)57d z%CM$NiNR%%07t7y*}l4?`TWeU|W?sttJXw(< z$rn1A3?<@g8v}QM!D@Uh$0D>ytTf&)*+Zh|l@^6qN6^Q3B(I|D^t7XRRo#eTmazFJr!T;8lSXB*-JA?W0vz*Ob$vH>owz`uAJ z)lp3P0Yt6Z>!M$<1`jImKTGb}Z-ZORLa-xwiq9yx?7TiXw`)1zN1Njt<_QOneE!9i zhH{Y=L{5pqvN}mwn}^Wwt|5P? z;HAWh*|Y2+RywQHR4E`JyV9&O2Yka%1?Z^I5bqV#e3JZx?T1momptmDL%qR|wcPNi zl(T->A*w*sYD?OYOuWxWn1a4_a-#Z4!LP%ShBc1l<)@`^-ISk` zq~;6D2?e84Wg`9RT=#12G#`tP`EUKOI+M$e6sA=VqR~xY-%T6h=lWaH8z%NjtF+N* zd0y?&i&C^W6{sr6`v~zaUC1?hY;I&Ow82 z@V5Hk>3_RZ1k0&K0!zBNoWDvbN+02(RdC*C*_oGJga zJc2Mn0l-00Mk{-Zm>;n?d`l7@GKd*@eh08%s`&>=he2A!BPe3$a>Q@beSNc+KE(nte!J{HQjqJOiSEo4 zQl#Wys#irUO5yrRK7;9ph>Uf+!R!sk&0jlH=E32EJKrtAq!+}yy5!B_qiRjsN}Q46 zxgM@I{eFUq9}PxsOCFiqGsD!!_C}_UbcocvTXXKMfHd$d+-vz4oRrj?o}ON;lH2i} zLQB{X)@CGxfIFbi&cLA4<@;b}Y8p!>oRyQ~$fm6uqhq-fI!-38CdOnx#Ptc**CUzd zVJNl}Fk@|zp)jY?LWPEQk1fq}$vwF_W@iOZJ1Ugs{h=nr_nyz+c0SS{SDCz~g^wVX z9`Xr{R=%tN%+p=1+JyqxcPg%sZR?X3uKsJ~8klHpe z%n3x_m0o@YPn~W+XEse)j#~kd*jpoR+0mmYV~L}^tl>c|1483Hx>&FDuHks{H!e;z zWECX!wF>jYTfb6IqQhX?5{l-76qN4 z;kv6{EuAyUJJLR%rA)Se5l&!y`>-Jnw<<7y42q!&3I{wupjpNi**jjp-_HN9Gt3JT z*V#eb8AdBM3S<$#rqHur(f4s}Q3RzoD4S*|yqFDJz;2 zh6$Uat4oB5WRAe6Q16D$Ks}XgHtCNj=k9BqPO0?wRomRpTL>cxni#3)7lXBTi)Z#ODi(g5 zk>4ECgx3n!eirbh5JwNZPu7iHSu~7$b*kPIXl<}xj%asxKpCks1hB=7?ti4$shXL{ z)^FSi-KmX4vgL>SqB6@UTcO(SCAZfFup6`$pC(Jo7JN%fv)2}vK_tL}8Z%S)Ld8UaiA_41PP5nSL~%J6YWi`L;k1{QU4h-!^nrXVy-_qloRfa7b)v@93Ohl| zId`7DnOky(zpK!5C+MApcn+XI;!jO~nu91lCWH!b&U%ZBc&NpRavSE>m%!1>w`5hw z`h$mzWYx|2R#NoYFDS*V-9IZ;iS}_2JTD+?39VW-ci1U)YkEaPGbel=$Av(+$LXm# z?Ck3I>oS@T>!zE!gG4MdKyRzi8{RYAww55T3~%!!ap+rD3iBXMq$+{d&9%wPKUP5! z&a++l1g#Q9i zs}gBR=30nm;XEF3fu@o5RJ~w!X4!mBDU=m@CT$+)i*A%}PF1UuZ<9B6UfDiPgXl*{O+MC8Wr;GmbEk;Sb}sSsyE zWi<4oPErn+gVvQG7e$s=X@&SR3^H*AB>(w=<4{tiTK}Gy9%sgYRpB#QV`I`NCpL<` zrcQ!mh@j?Vf{Ac>jrRC<&3bfRqS@T3$&FQ?;yn1JIpgvkiAIhXgXK3Y1_h6RiMss) zTiy~HeKP*F!Js3~)!F5OiM7zM?YmIiilehwI|u$3O8(lK=*J&Bj&?YDS`_u?x!(V+ ztPVrMmn#8s?rnrPbH8#GiZbL7ovT2e&hf?^UK3lfW2#Xn6O+e}9K{2KiozlvB zUeKY3ckPeYuBi$`l#hRERjX6c&NUMGW;Z;`oCm#g@`cc?1PgfxOK()X2%`9f^V z1lzimiG@VvN`nrCj}}xE+Yw(mGo{Kh^dPY(k3SfRs~-!_$xQNQRb#`8hm-GDA*HIC z*AKB@B3$T#_+uZK3n~#lfobZe%)96^+uDL#U_zXSNalfnL9SaodyVA^l*}D>!?T81 z!V%LIN{$`G_`txZA^PO;-s>-e4aSy;JHM4k1!qfbG>Q*>k1R!(dJq?*DOT8{(y^nC&ot?39xNYm9GB%b?~n zG_dql`-&GOJzsS!`Doh~jY~Z(97MZ2savww|7a2T!2zVD?>7|8XKQF^t}AnXWA$m>9;gz0wS`pH+C(ZFWWtWYXvpO zEfpX-V)v0hUke*@cpNb~pqy;=gYNho20}O^SpCrgX}~qDlIl;9M4absWfOg_&?OZ6 z6}=X9V&2mv(@nD#7UtkbAMqCzh|vcL+B;4I6LaWQ$TnNOX<1}psgc_N5l?{4V%@o= z_`;a%55VWCa%^i;jc`Q>s70ArrwpX?g1D@z+VUN1Q$jtDp1X}Ht!SAGIwR7AONfkS zfn)*|m->!}oRy8f5F$BV^7133yMV zkhIcb(-{z}+{^{lJE{foc->4Fr?NbGYz2BL>OX#aMI+|!+KG7`+tj){dvaQBs&six zcia7IQp?Q+RSS81y`H8s>UddT3d11(GOAmuo-Fvdetpa|$w?toKF9ecq ztg@Xyrs{fDRLtvUYv{FX8QwOmY7-=&MS`I{lO~ivk{0<`Y&ej)e7>R#0-p7HRKYj8 z)j)K_C&kkamnJaH@;})7`Na2+xPG@IS>)|4@Er^+!De;3PR&6es^CqZRR^z3wYtk~ zVgFH9J5^qB(@TPBw&&auix)GG82fz~jUPQB8HW3zs?c`a(f0Ibno-X*fs=Kn9#zXR z%XmyzHgiMGY4boNg zb^;5Tt+W0c^NF;LD;Ip1@)VoY%0Z$gHc0+J1)?GHK z%C(u_cJ4RFc8_!RF+1ar2d`;n-sll`>YjTQoWRK}h8-~XD_`a}npFi1Hgda-?TiV7Hm8*alhK9oZGuceotzCn$8xoNZ4l@=9D4|o&E+^>h8wXK z7p7`Daev`!EO&^jNy7woIeo4aMHD6`bWyPJi>FYA3z$y$mo|BV0pN5TE{~rs8 zqagQvx&5g`%v&N*_q+J*{(-pqiMD|eZSL6mDUI3k8uQI4R`e35^+U7$GQfG$xaQdL zc&QY;$4KsX^Itu=4%#4PC#luH-4G7K=xZY;&K_ya_l}B)pm5)>sDmG+#Er!EMY&DPVEc60(VO*EvM zGj=8Fg(D$zC^!%Qc2owVUqa;ET{O39R1Fi^RO!lQU}I_52a_JBb8h1qTJXxn#y2nr z^89stQc3A71FZL5bi8DIP1V6mlyIY3z>Y={%JQsB8@0T5j zz^!(3%O1-0yu04YeZJp>DB5AW0BK{l*c$;r-xhtO7e5)i!Z7h}4IzRI*cW{`z-*1c zq$Ot29fo?=V4w)V(4DN^X5|*ThXK@)rPPRw5v4x(oH3)1JAubN{6PY{^Oz)ciuh1X z6!kpuL1-<2V$RTlMGbG4XjGj@0~^swzRlJ`d=Hr$PglMkko7~36LCf!FFaUv0rYh> z3{h%&lv~0>U;kcizOeVP;=F4r6pQl2ZQ;m?3=v;7iv;6&1NI0N_giyVqtBJS;X=K= z#ddBie@w6^*R$AFg$VjZvEn|Od}Z<@s1n&Ku`dg%P$=jQu(0^7LHxb$>!Re3dMrUq zK< zSv_1)FjpRz@wTgRGrc-A9$J_Y!stLZ*E2+p-uj(g%YE^;_-x2F_zza+4^{<1$e%)^ zCVQmuOZt@-=h;Cez2{B3i&@xMRFtYa?O?rqZS|zNyZM@n5*~^*9`AMa5oG+I0o6&< z>ba25-Kkx5SDZV0rETM}$GYEJZEWc2MrURS6)#&+H2FPBx)u1)Ax|iTFCq=iR{hs< zn%WSTJrx8Cmf`oOCya(+zul>OrQO3R{0mLH`-$^pCk8tC9a5p!TW8Olnp|Re6{CXm z&Z<)ZR;A4|C-m0K#3_s2f+6V#F4T+R0MoE#r{otFEb@to$%<`1U^m566% zXNr}x6%nEGQafYmd_tvzBt7o+*o?eM-4iyQ z?q3s56jDu!-i!aoaIrw_iLE=}K_EVv)7?4(YMhR14MYhhp2B%bMX zdPnaJ#VDn*r3i?1dZV46L&&qePtGVreczaY)(B+YHs@R4&;E$VCa@_xh9tp#A7v=6~ZnsaYzO80;dQQ&k zq-edR;#>B^V$Jbfsf-OWE&f`a+w5U3`Fg&sGIVDCjLODTD7&DUcVLR> z_DdU21D`#`v~1N~O_!+MQxi2|3<-rqb}3ycMQmZ+)K^(_!#~FJ%OW_^u@%cI^q~pL zpDcGfHLKUIBQW*8=^pX^ zNH8*kVD~4t`}}Fo-o&(6#GZ)6Y*&xOz>r#}Db-JOU^g=k4X@*1B&A>S!@Ozk?6k3FqsI!`^GLnX=!u#cE=;X)eLT?RMw}|s}kK(`F3~Gkrbx4O-TDtEV19s;nqP8 zhNf6*UDe+uuDS4`-gk6 z$?K_7$J)I(DQ`^6LMHb0a&6h=W^skPeADw~AQGSD5n^9BqE*)9;IpG;F&e)m>^@dv zHso7P=e09VF=Bo-1(mQEO?kS@#OgRzf``edd%DJiyxDfXO?9hxXE3IyFPTZFb1M{m zZzOfI-s*m-HXq}3bI5nLLsz}das4s9PRMTmhTp{!#etd7RnEmzU#DCd;qmno8Ku+KbiRz^na|U)7Sk*z$K#XLoKrXF?$bq=u%|VoX68e907;?G zc`Ic8I9Va}Spky7Vz6J;e&Ka8zWDFq%Yed7_fywyAgo-cnn=_!Y7(iGXi^C$IXYJs zSr+w%-Q*c5Mrs$gphmlLr2wH(BwJod>198&dZEfd!7|X@-bC7?!4ln!fGC<``ybwt z1OWg!aU8$MQ*$8~ZtYMiQ6DzQcVZKD{w^BPxhxuO^}??ACuuG=x2T44;Z6@Kff!C0 z9~P%5`L_2;g4nH4wBs@?R)dzoX0bk_)^!F4=Dg#X%opQ$WSp6iq zz$vzv9$ah$4l&HpSK=7qVpNiUh#l?OftFjmAR^S2T25pcRvY@Yg_r|Rk2rv09{1g% zRKhQ#tYMYF`iv9-xYelf8XY`>%z)9Ku12@oJudh>|GullOk&c{nbE1xO_w%ro0U}$ zc>i%UiJ{$PC?+j!>Gg&Dm*Cl|Q`btJ7u5jl%MPHlhx@GF_}aZoPx$eh~?z- z>6=JYtis_;VX^uHyauzm?Hv7_zOFZRV>_mv)T6psV=Z@k`f_lpLLRq}hbU5wGubd|RA zWfFG>3(T3=+t}xAPXTk+rBpKh%K6_;cpTiMkQxi^H7$Vv&U!_Y)cA@MMZ5=_Bv%$4 zo}|J~eklgMfS$fO9+vW*S7p-U{a%s8sd3ie2}U(iWN#*~aNhXGSmUxkOQwvA>F5E( z`l+2fIA3fWy=c)thB|A>U{<3VR6i7Jwh*{|N*vgO+_AiI>O0hl7jLlfj#Es@ zXYY7dA(ov6hfZ~BZ|Eh(-Q8)<_lY5tBl2gQTIIuI1@)x@-(*uP=!wJd-s9l*_w_KM zg^o$l+wGO5osr_8Fy~jv={Q^UFl@KP1$vA%YXO{mecvnnygyra zaXWi_&L|(z{)+PbWP#ay=l0xXXpO&KSH}WN`Vs-kdUZ(3stw^Ik(nmH+0Y^BI0TMhJYbLYc_#2P-&is*Y>XOo=T@mccJrunM>yJ zwY|FdjlWT0F((8C7dqDFdLo4l-Rcv4xQ=Du!e-JesnKQSO1x2ezq8$yFZX9b$>SdK zOSg8rSS9<_w*R}H$=m&Ah>;(E7OL81YH-R?`wWry1F|m(pV!sg!`!uN1W~-DYbK8q z!R4M6+{JCUOa`r~BPr7sPnm;f2i+R&7Lj~wK`AhzwPPo~46Xf%Z=%*0t-)7FW&eWZeDy9!=`Tl7Fw>scpkU@LXvV*DNNkE*(qI#kH*-GQ=K{YxV zzu)8B(*=eAc(-Ou&{q8Yc*NT-=P_&Obd-RuRYng*oLx?b7Yu(KyMJ(ScQV(A5ZC9X zBi;9l=*vF|czLzoAJU)_iZ_-G(j`*F6QX7I_~Qs|MQQ{kaJQHaT+xw1{#_1{6%DU@ zY!Z``Tj|+wphQPTvA-Nb-&)z#)hcW)FigIV{=q&E#4R@vi%&Oi{zCsT=dZa)^{iN@S_Wpc9|r zhKmt}X1T>#7>$OyO13udU?RH^D=gygnFm$c6yg4OW~WJ?K!j$AYIu~1yo7VxvUPY2 z9+*Cd$>Usmv7yMKzCZZG%tI&C>1?%qakFgZ*1_qfGCk7AZ7 zIrD58<)}}@62sDNJM2_%Y0HK+@g_{#p zR`cI`%92sbw37=T(b-2vC>u1z%44NJ8Mv+BDHI142=@l>1oS-$Qn`X=`sWQ@^M%7q z=}D}53N^!*HPZ_sWiT_&D=f)K@x}Q~9nj2yi^*M%@L6g7qO^OQ&F{B zrh2~mft8->TRg7c4nll-$d^A{w+2%rCLi^q zC-cD4Q1d-z&9}f{f3UCj45LdlkBs3g4d8(pbY8b?0gLwo zuq$A@yPLH43dF}9@^@qPRfqat$2S$>Yg2nvLO?CEe&fTRm*!!J6t356*c^e}3dHy# zo=V1ct|NnEGMNeE;c_sBbU^E~ww(2kq8o0zdD`S*#k}i9gH5<&Cvz^DUWX2-{)k1!XS-`j6KLuxwM(6sxt5x&%otsMsmZ5 zbY3d4D{l*G>o(YIdg%{0(*_ug_>$#ZV~9TQR(Hpk9V4Nt2zi{_U->2s%;fFCw{t41 zwSFftr26_zwgzouTIL%#OnScaWqVwEtMxwkN*`6S_*iV!TEWThxsaacM3L6o!m})0 zDU=$l(dF)D93Pvl=uq4R{m03>Rf_)B4(0OuKQCQbC|HhXIKO5&rOemuH8Sb2W3KiOoqpa(3Wu*G|~ zk9st2XfjK9)M~EX{lfH>`y;ZE*IxA4m})!CN0BDF5F)kogHKO0YppJLtaqKryK|+9 zlR|>ud;isPZS96zSPD#(Og+0R4ElU_I$nYqt!k9%l#4~YaCmEaw_Pmn!1W}z-9iT( zGukay9UZE0&fjmSVYhBqmSy0<#;(rB>mCFDko8V|B~bhw+~30%r<&3@%eymTZ2s>- zsYlG9-PN@Byr;@Qa6Fecb_f9_RsAajOoqphrRd+ABBEn5+BZpB+>f$9iBhuP+zZpD z&V?HNRFRL|XAXRC;dq~5H0`l=UyEWy%K;31eMTTtkmF@d4yhY%V^hVbv*Dk2F3W+- zCCc_@i*h@BRy|B1+Sx1V%WWC~_v7A>>AY!JBAqzlhec*=FMK6obKdWk^$&HD|^D!Mi^|}4~avj7*O&)zh z)%x&2RL|#aRmlvQ6`khwqFcBlui0T$QCU1ey+e|rj`Kc&`!KcLVpRIdWJcxlI%2VM zZiQy4?E^Ay1U{3PxJnM+M5@`Lqlz#jXOvMbZ?|e)aK>--r=P&ZQdgy7dindpxG1u2 zwX)P9i9IxtR@PHO)jRBwB8+9i8}H?tQp#GV(Yk%~1g*1);J2Tha`W|EGSnsk%xT<_ zo}v`2UFqlY4tltujv(8_3&Fcf+un&@*b41Z!3* z*Sk(#lUmpoWIBM&I;j?9nl_n+)1*McWJF=T-;dm@e*ryL&t80PPIoqu28)!^B{^-X zCQM$@g@9Q%!zT-sU4<&$rUMFOLN&3dy>f|z{~%eMFgClj2C49En$hHH*%TEJh@=pj z>$w?NoqM*1JxoAo>AH1g@wQN+cGCUkPA=%JTN3)CV)de>*lJQAmiMc>I8NR?OSX&r zg{j`EYEIa`4KTxMK3s#_i{LCXnz{*F*FH{rVFDAWKMs!;gvAr|Py_qM5k{{~+m$?t2z)5(ZF< zZ2Uxa`y~o&gi*cVv&Dq3_QHRKtgLWO;(pdv_te?a6vhMRclBe&T*z^y?oojbjDXz_(hzRqYK@_lspIErSN#+Rm<2yB0~e_DIg( zssxGz-oDg)*{R8#WnTzK;w|PcLI5$%rdyJg<}V+6goC$<5(a)T`j7Vdikd{?b#(@g zS^bFDnQ9iDx}ZZP*=qlk4v8h_zZ6GFd$ogwQsr*F@AiqS6-)Hb7RPr#Sx8qYJ0d6a ze6{|jG!mfh(NbzQJa_5y85Sw)zT+eDNkf}iuSxIErsA+4e?V4R(7^b&X!$%zEqZFy zkOWEe2084Z+xF`mH@n3Tv2v{mV9w1PwR5QDXbSWCh0lzh`hi$ZZFHyOd7>x;47Ug( z2Wn!^<47AB8;-r0(jUD^ zV7a2An7K-gI`^5=0Npu7i48|*`7E>7qJ*`2)%)j%W`Pz!c0u^b$t+8Q+NQsnVRpPZ z5ChS`a9u}2BGCEcN4n~j9ryeAraDct!1J8>Np@}xt75c#l)fb<`fx!+Qn$!&(P`1m zMVkaTWRNQbRIR_gA6*tI44o;?dLiu|Pq`E`lstl7^9$AbZf=zVyIrH%bd&o^C#^^~ ze}2%x65xL6De&R#=6p65eMY6jDK)j=jE+eMV6(n2RhKl(s9)=Jh1NLXMAK?F;qC7zJoJ!&@vDWJC1n{*1=%c%uEH zc=+!Yxsm3+P?wxBOP6RIxm>4bfIRcW`TOf{^PKOy;!e4+|DWq*Q z^S=A-b+y$j>^QVW!e|^$e=GH?Mers4AY3hVEsI5e&Z4ZPLyg*T;z+UO0Wx&{tC=Pi<5YMWUk#5*|~$>(-cC6xv;v zQ&Q@@U05CFrGMgaSeJ$*@P7+0QqKI6mY5cWs{Yj{zcu2agz{vtVM<>*h-N+`F(OP1 z9hGiuaP_zOIW7su(m|?5r-ti>kQDZ27>!=TDUr9G^|yukNi=(Q$cAw)^l&tW7~G4T zu88;Rf_=ItpqSmysT9fU^UGVjMI9moA!O|)-743r&e-(i&i6QzHyH3Nqs9JOj$lPo zWYl98_1q!oZg01~-)1Zte}Deu^mONdJs6Ah>?)_IJA-25yxq?p$Y>RLQ7Ca$q(of4 zSfW;AEH)B7bFtoDtanQ^GN~K>rSR_jMFp!YxZ~ngz~(t)o(aB~w7Alm$b5>0VJLoc zgv$yzIq|SBcEd`6GfO>8@0UjW7s04KWL!qXg^9E*clNVM(IVwHvlX{RxaU0|EL6Z( z$jB56=iEHZ5`_e4?0&vwzDkB4_ZwSUA~Glr$c-g4-9GcT{crzAr%WR1a}Fr%XmelZ zHM~bE1dSw4{nCVjmjhm&%Csw?B`VStWk_z&3LC}3aa1StMu5X`Mcq_aMkMG(^~X5f=fzJxREH-nu4ssX*Lv0qkLS*wV&KPUI2HfB zEHRCiU8W;7_N8fOpRG3AZ*&CkOWd{pTLD4Lq%HU19MoD{Nu7wtvCDK%ebZ4Q9ryYT z&&smS&*x1+nI$bLEmD+PuKUz{C0;aw_MJ>DS(SRD9vtTmMUz_S_Mfc|UpI@LhBoZ` z{QKLpO`|Z~kxQL$UGse_&*%OkIr=yhZwjVUN&--R9SeFY70_m(8+ZCOT$Vh_$;lzr zT4!)?Ma2Io`O2phUGf73e~jwIOWg4qpHWk?RaHAjeA`(BJQ{%)fS2dyl5yd%*~RT~ z4w@8xlfNZuly%sX$+lG0D%ZNFyCG!&=20i>W%-4q@IO;YgI@y;+V81j12515>m>Kt zkJ9N>$VO}rCP?R4G>R3sLn!iPQ#iHyg1hlG9{a*Dv`W?Cr0*D;HVR3JNRQt)gNZvml)~zI~prCVbYCDDG=K4j}KhSaACm8^Y7#Tgwh9i~PmHw)1L2**U zw9_#0O^R`XBSG;O&O&2kl_U@wuq8^sg&k}=M~dt{dnD_t`+5?Yf4_{Lv$~JqaGshkFYMt>)YSt z$Ug!tbamRD$UbO2c++Wi6c%u~S(<+T z5xM^=n{S!K=_h)afz^LTxnzd*7Rqs)uw~?`Xa~Y6MT8CQS>ZrHEbu-=a(bo z#Zb&qSY2kAPiq9QnX0+`jc=qRFCP&e4a?@msMHH7+o>bm1~ z2Q^RJe|R{WEp|V@FLx98KNhgGQE8`g_22$@VzXIs6?}J846X1p?z!Efx%{s`_l#xA(~vI+C0^m0J7G z#qr067KggMZiAh!p8JR!KlUk-yrhI}-j&tLJZH`q+TjljX@#H7 z#M3`9rIG-R#U^SC;Wes4mqRFrmk|rsFZ6h zy<@23&IGrIQ-`9sWP-o6)W);UEUs{2bx5aiRNHM|(OsaWiSnJndE6~cwFqKrc~L9V zfQ_0^uzP)GaR)Ct88nWObTER1*RU}Vm-b66h0}r0e&_dTmI!GUg2|gEwR7bh@wg1< z7}KdV&#SJ7H}JCV9o^MnkP9_R+s@ug3>GR-s~o({M6a~I1SmvWU(EdVsPZ{RajNUq zo8@~|-vd&9;hoZz6oR6i_-&Y$@v8ih5fp;s!nWTS8DAYN=4&{}au~?Mb#;5!nz!cm zRf-`*`svNv(kZ-Z<+|?^1ITXh$07m9uaG5NJe0(Ci>}pNArz#%q4Kpwr6$_tyqKtu z7fMW1(Q9}hj=sKJP~>bZB1!JxV>I7LwtWHfdTWT&;oMknwO+e`QOMQW9-QV?pD5`j~2s@Yoa+}k7V59+*x z58X6^J3HODS&XeWb3ELxrwf+>gpBH9yNx(Jbz9s%SG^z3X7%Lb<8!p=diba1@S*

jqvBVRlZal7Fpwp!~| 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 VN=c>&__!y&+5f>9( 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 J?`2Gb{ttT|HfaC= literal 0 HcmV?d00001 From eaeb0d270b44d3e9a6ebc2f8a2bf93211b2d1b8e Mon Sep 17 00:00:00 2001 From: Miguel Nieto <39246879+miguelnietoa@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:09:44 -0500 Subject: [PATCH 07/34] Remove dead legacy stylesheet --- frontend/scss/_main.scss | 147 --------------------------------------- frontend/scss/index.scss | 1 - 2 files changed, 148 deletions(-) delete mode 100644 frontend/scss/_main.scss diff --git a/frontend/scss/_main.scss b/frontend/scss/_main.scss deleted file mode 100644 index 11a529df..00000000 --- a/frontend/scss/_main.scss +++ /dev/null @@ -1,147 +0,0 @@ -.margin-top10 { - margin-top: 10px; -} - -.small { - font-size: 0.8em; -} - -.gray { - color: var(--color-text-faint); -} - -.clear { - clear: both; -} - -.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/index.scss b/frontend/scss/index.scss index 016d270f..bcbea8d2 100644 --- a/frontend/scss/index.scss +++ b/frontend/scss/index.scss @@ -3,5 +3,4 @@ @import "_base"; @import "_components"; @import "_charts"; -@import "_main"; @import "_force"; From 61e11d59975d7fff0fe3999526d7843788578e80 Mon Sep 17 00:00:00 2001 From: Miguel Nieto <39246879+miguelnietoa@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:33:24 -0500 Subject: [PATCH 08/34] Split vendor chunks for faster loads --- vite.config.js | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/vite.config.js b/vite.config.js index 2ca2b518..e6e23345 100644 --- a/vite.config.js +++ b/vite.config.js @@ -21,6 +21,18 @@ export default defineConfig({ }), ], + build: { + rollupOptions: { + output: { + manualChunks: { + react: ["react", "react-dom"], + "stellar-sdk": ["@stellar/stellar-sdk"], + d3: ["d3"], + }, + }, + }, + }, + // API proxy to backend server: { proxy: { From 807a23b2996857fff9c57dfc2b091b95c3f63a07 Mon Sep 17 00:00:00 2001 From: Miguel Nieto <39246879+miguelnietoa@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:33:52 -0500 Subject: [PATCH 09/34] Add date labels to 30-day chart --- frontend/components/D3BarChartNoXLabels.jsx | 20 +++++++++++++++++++ .../PublicNetworkLedgersHistoryChart.jsx | 3 ++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/frontend/components/D3BarChartNoXLabels.jsx b/frontend/components/D3BarChartNoXLabels.jsx index 36c4ea2e..6742b67d 100644 --- a/frontend/components/D3BarChartNoXLabels.jsx +++ b/frontend/components/D3BarChartNoXLabels.jsx @@ -18,6 +18,7 @@ export default function D3BarChartNoXLabels({ 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. @@ -158,6 +159,24 @@ export default function D3BarChartNoXLabels({ 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); + } + // Hover layer: full-height hit targets, one per bar. const tooltip = createTooltip(); if (data.length === 2) { @@ -200,6 +219,7 @@ export default function D3BarChartNoXLabels({ yAxisStep, tooltipTitle, valueFormat, + xLabelEvery, themeTick, ]); diff --git a/frontend/components/PublicNetworkLedgersHistoryChart.jsx b/frontend/components/PublicNetworkLedgersHistoryChart.jsx index f574a0d3..6a5999a1 100644 --- a/frontend/components/PublicNetworkLedgersHistoryChart.jsx +++ b/frontend/components/PublicNetworkLedgersHistoryChart.jsx @@ -82,10 +82,11 @@ export default class PublicNetworkLedgersHistoryChart extends React.Component { data={this.state.data} width={this.state.chartWidth} height={this.state.chartHeight} - margin={{ top: 10, bottom: 8, left: 40, right: 10 }} + margin={{ top: 10, bottom: 26, left: 40, right: 10 }} yAxisMax={10000000} yAxisStep={1000000} tickFormat={d3.format(".1s")} + xLabelEvery={5} /> )} From dd5613aa2863d59fe51bf4cd43186deb172ba28d Mon Sep 17 00:00:00 2001 From: Miguel Nieto <39246879+miguelnietoa@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:34:46 -0500 Subject: [PATCH 10/34] Redesign fee stats with percentile chart and expander --- frontend/components/FeeStats.jsx | 116 ++++++++++++++++++++++++++----- frontend/scss/_components.scss | 69 ++++++++++++++++++ 2 files changed, 167 insertions(+), 18 deletions(-) diff --git a/frontend/components/FeeStats.jsx b/frontend/components/FeeStats.jsx index b69b9474..185da158 100644 --- a/frontend/components/FeeStats.jsx +++ b/frontend/components/FeeStats.jsx @@ -6,8 +6,21 @@ 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.percentiles = [ + "p10", + "p20", + "p30", + "p40", + "p50", + "p60", + "p70", + "p80", + "p90", + "p95", + "p99", + ]; this.nameMap = [ { id: "ledger_capacity_usage", name: "Capacity usage" }, { id: "max_fee.max", name: "Max accepted fee" }, @@ -59,6 +72,12 @@ export default class FeeStats extends React.Component { render() { const capacity = get(this.state.stats, "ledger_capacity_usage"); + const pValues = this.percentiles.map((p) => ({ + p, + value: Number(get(this.state.stats, `max_fee.${p}`)), + })); + // Fees span orders of magnitude — log-normalize the mini-chart heights. + const maxLog = Math.max(...pValues.map((d) => Math.log10(d.value + 1)), 1); return ( @@ -73,9 +92,7 @@ export default class FeeStats extends React.Component {

Capacity usage - + {Math.round(capacity * 100)}%
@@ -88,20 +105,83 @@ export default class FeeStats extends React.Component { >
- - - {this.nameMap - .filter((field) => field.id !== "ledger_capacity_usage") - .map((field) => ( - - - - - ))} - -
{field.name} - {get(this.state.stats, field.id)} -
+ +
+
+
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")} +
+
+
+ +
+
Accepted fee by percentile
+
+ {pValues.map((d) => ( +
+
+ + {d.p.replace("p", "")} + +
+ ))} +
+
+ + {this.state.expanded ? ( + + + {this.nameMap + .filter((field) => field.id !== "ledger_capacity_usage") + .map((field) => ( + + + + + ))} + +
{field.name} + {get(this.state.stats, field.id)} +
+ ) : null} + + )} diff --git a/frontend/scss/_components.scss b/frontend/scss/_components.scss index 4f7de34e..e75b53e1 100644 --- a/frontend/scss/_components.scss +++ b/frontend/scss/_components.scss @@ -601,6 +601,75 @@ color: var(--color-danger); } +.fee-summary { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin-bottom: 16px; +} + +.fee-percentiles { + margin-bottom: 4px; + + > .stat-label { + margin-bottom: 8px; + } +} + +.fee-percentiles-bars { + display: flex; + align-items: flex-end; + gap: 4px; + height: 64px; +} + +.fee-bar { + flex: 1; + height: 100%; + display: flex; + flex-direction: column; + justify-content: flex-end; + align-items: center; + gap: 4px; + cursor: default; + + .fee-bar-fill { + width: 100%; + max-width: 22px; + border-radius: 2px 2px 0 0; + background: var(--chart-primary); + opacity: 0.85; + transition: opacity var(--transition-fast); + } + + &:hover .fee-bar-fill { + opacity: 1; + } + + .fee-bar-label { + font-size: 10px; + color: var(--color-text-faint); + font-variant-numeric: tabular-nums; + } +} + +.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) From d1dba315ac806a47a89603edd00c1bf644165ce0 Mon Sep 17 00:00:00 2001 From: Miguel Nieto <39246879+miguelnietoa@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:35:19 -0500 Subject: [PATCH 11/34] Show known account names in operation streams --- frontend/components/AccountBadge.jsx | 41 +++++++++++++++++----------- frontend/scss/_components.scss | 17 ++++-------- 2 files changed, 31 insertions(+), 27 deletions(-) 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/scss/_components.scss b/frontend/scss/_components.scss index e75b53e1..2dec65a4 100644 --- a/frontend/scss/_components.scss +++ b/frontend/scss/_components.scss @@ -374,23 +374,18 @@ } } -.account-tag { +.account-known { display: inline-block; - font-size: 10px; + font-size: 11px; font-weight: 600; - letter-spacing: 0.04em; - text-transform: uppercase; - padding: 1px 5px; - margin-left: 4px; + letter-spacing: 0.03em; + padding: 1px 6px; border-radius: var(--radius-chip); background: var(--color-border); - color: var(--color-text-secondary); } -.account img { - height: 12px; - margin-left: 4px; - vertical-align: -1px; +:root[data-theme="light"] .account-known { + background: #ececec; } // --------------------------------------------------------------------------- From 6ee090b913406d22714385a386fc16b63316253b Mon Sep 17 00:00:00 2001 From: Miguel Nieto <39246879+miguelnietoa@users.noreply.github.com> Date: Wed, 8 Jul 2026 20:43:42 -0500 Subject: [PATCH 12/34] Add supply distribution bar --- frontend/components/App.jsx | 310 +++++++++++++-------- frontend/components/SupplyDistribution.jsx | 117 ++++++++ frontend/scss/_components.scss | 164 +++++++++++ 3 files changed, 478 insertions(+), 113 deletions(-) create mode 100644 frontend/components/SupplyDistribution.jsx diff --git a/frontend/components/App.jsx b/frontend/components/App.jsx index b481f17d..3702a0a0 100644 --- a/frontend/components/App.jsx +++ b/frontend/components/App.jsx @@ -18,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"; @@ -26,6 +27,29 @@ 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: "Live network", + horizonURL: horizonLive, + newLedgerEventName: LIVE_NEW_LEDGER, + path: "/", + title: "Stellar Network Dashboard", + }, + test: { + label: "Test network", + horizonURL: horizonTest, + newLedgerEventName: TEST_NEW_LEDGER, + path: "/testnet", + title: "Stellar Network Dashboard · Testnet", + }, +}; + +function networkFromPath(pathname) { + return pathname.indexOf("/testnet") === 0 ? "test" : "live"; +} + export default class App extends React.Component { constructor(props) { super(props); @@ -81,12 +105,25 @@ export default class App extends React.Component { may4 = true; } - this.state = { forceTheme, may4 }; + this.startedStreams = {}; + this.state = { + forceTheme, + may4, + network: networkFromPath(window.location.pathname), + }; } 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.onPopState = () => { + const network = networkFromPath(window.location.pathname); + this.ensureStream(network); + document.title = NETWORKS[network].title; + this.setState({ network }); + }; + window.addEventListener("popstate", this.onPopState); this.getStatusPageData(); this.statusPageUpdateInterval = setInterval( @@ -97,6 +134,28 @@ 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 }); } getStatusPageData() { @@ -164,122 +223,53 @@ export default class App extends React.Component { return false; } - render() { + renderLiveMain() { + const net = NETWORKS.live; 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} -
- -
-

Live network

+

Network activity

- - +
@@ -299,6 +289,9 @@ export default class App extends React.Component {
+
+ +

How these numbers are calculated:{" "} @@ -329,58 +322,149 @@ export default class App extends React.Component {

+ + + ); + } + renderTestMain() { + const net = NETWORKS.test; + return ( +
+ +
-

Test network

+

Network activity

-
-
+ + + ); + } + + render() { + 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()}