add amendment caching

This commit is contained in:
Oliver Eggert
2025-09-30 16:04:18 -07:00
parent 96a212a277
commit 514da6376f
2 changed files with 393 additions and 345 deletions

View File

@@ -0,0 +1,391 @@
import * as React from 'react';
import { Link } from '@redocly/theme/components/Link/Link';
import { useThemeHooks } from '@redocly/theme/core/hooks';
type Amendment = {
name: string;
rippled_version: string;
tx_hash?: string;
consensus?: string;
date?: string;
id: string;
eta?: string;
};
type AmendmentsResponse = {
amendments: Amendment[];
};
type AmendmentsCachePayload = {
timestamp: number;
amendments: Amendment[];
}
// API data caching
const amendmentsEndpoint = 'https://vhs.prod.ripplex.io/v1/network/amendments/vote/main/';
const amendmentsCacheKey = 'xrpl.amendments.mainnet.cache';
const amendmentsTTL = 12 * 60 * 60 * 1000; // 12 hours in milliseconds
function readAmendmentsCache(): Amendment[] | null {
if (typeof window === 'undefined') return null;
try {
const raw = window.localStorage.getItem(amendmentsCacheKey);
if (!raw) return null;
const parsed: AmendmentsCachePayload = JSON.parse(raw);
if (!parsed || !Array.isArray(parsed.amendments)) return null;
const fresh = Date.now() - parsed.timestamp < amendmentsTTL;
return fresh ? parsed.amendments : null;
} catch {
return null;
}
}
function writeAmendmentsCache(amendments: Amendment[]) {
if (typeof window === 'undefined') return;
try {
const payload: AmendmentsCachePayload = { timestamp: Date.now(), amendments };
window.localStorage.setItem(amendmentsCacheKey, JSON.stringify(payload));
} catch {
// Ignore quota or serialization errors
}
}
// Sort amendments table
function sortAmendments(list: Amendment[]): Amendment[] {
const getStatusPriority = (amendment: Amendment): number => {
if (amendment.eta) return 0; // Expected
if (amendment.consensus) return 1; // Open for Voting
if (amendment.tx_hash) return 2; // Enabled
return 3; // Fallback
};
return [...list].sort((a, b) => {
const priorityA = getStatusPriority(a);
const priorityB = getStatusPriority(b);
if (priorityA !== priorityB) return priorityA - priorityB;
if (a.rippled_version !== b.rippled_version) {
return b.rippled_version.localeCompare(a.rippled_version, undefined, { numeric: true });
}
return a.name.localeCompare(b.name);
});
}
// Generate amendments table with live mainnet data
export function AmendmentsTable() {
const [amendments, setAmendments] = React.useState<Amendment[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const { useTranslate } = useThemeHooks();
const { translate } = useTranslate();
React.useEffect(() => {
const fetchAmendments = async () => {
try {
setLoading(true);
// 1. Try cache first
const cached = readAmendmentsCache();
if (cached) {
setAmendments(sortAmendments(cached));
return; // Use current cache (fresh)
}
// 2. Fetch new data if cache is stale
const response = await fetch(amendmentsEndpoint);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: AmendmentsResponse = await response.json();
writeAmendmentsCache(data.amendments);
setAmendments(sortAmendments(data.amendments));
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch amendments');
} finally {
setLoading(false);
}
};
fetchAmendments();
}, []);
// Fancy schmancy loading icon
if (loading) {
return (
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', padding: '2rem' }}>
<div className="spinner-border text-primary" role="status">
<span className="sr-only">{translate("amendment.loading", "Loading amendments...")}</span>
</div>
<div style={{ marginTop: '1rem' }}>{translate("amendment.loading", "Loading amendments...")}</div>
</div>
);
}
if (error) {
return (
<div className="alert alert-danger" role="alert">
<strong>{translate("amendment.error", "Error loading amendments:")}:</strong> {error}
</div>
);
}
// Render amendment table
return (
<div className="md-table-wrapper">
<table className="md">
<thead>
<tr>
<th align="left" data-label="Name">{translate("amendment.table.name", "Name")}</th>
<th align="left" data-label="Introduced">{translate("amendment.table.introduced", "Introduced")}</th>
<th align="left" data-label="Status">{translate("amendment.table.status", "Status")}</th>
</tr>
</thead>
<tbody>
{amendments.map((amendment) => (
<tr key={amendment.id}>
<td align="left">
<Link to={`#${amendment.name.toLowerCase()}`}>{amendment.name}</Link>
</td>
<td align="left">{amendment.rippled_version}</td>
<td align="left">
<AmendmentBadge amendment={amendment} />
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function AmendmentBadge(props: { amendment: Amendment }) {
const [status, setStatus] = React.useState<string>('Loading...');
const [href, setHref] = React.useState<string | undefined>(undefined);
const [color, setColor] = React.useState<string>('blue');
const { useTranslate } = useThemeHooks();
const { translate } = useTranslate();
const enabledLabel = translate("amendment.status.enabled", "Enabled");
const votingLabel = translate("amendment.status.openForVoting", "Open for Voting");
const etaLabel = translate("amendment.status.eta", "Expected");
React.useEffect(() => {
const amendment = props.amendment;
// Check if amendment is enabled (has tx_hash)
if (amendment.tx_hash) {
const enabledDate = new Date(amendment.date).toISOString().split('T')[0];
setStatus(`${enabledLabel}: ${enabledDate}`);
setColor('green');
setHref(`https://livenet.xrpl.org/transactions/${amendment.tx_hash}`);
}
// Check if expected activation is provided (has eta field)
else if (amendment.eta) {
let etaDate = new Date(amendment.eta).toISOString().split('T')[0];
setStatus(`${etaLabel}: ${etaDate}`);
setColor('blue');
setHref(undefined);
}
// Check if amendment is currently being voted on (has consensus field)
else if (amendment.consensus) {
setStatus(`${votingLabel}: ${amendment.consensus}`);
setColor('80d0e0');
setHref(undefined); // No link for voting amendments
}
}, [props.amendment, enabledLabel, etaLabel, votingLabel]);
// Split the status at the colon to create two-color badge
const parts = status.split(':');
const label = shieldsIoEscape(parts[0]);
const message = shieldsIoEscape(parts.slice(1).join(':'));
const badgeUrl = `https://img.shields.io/badge/${label}-${message}-${color}`;
if (href) {
return (
<Link to={href} target="_blank">
<img src={badgeUrl} alt={status} className="shield" />
</Link>
);
}
return <img src={badgeUrl} alt={status} className="shield" />;
}
export function AmendmentDisclaimer(props: {
name: string,
compact: boolean
}) {
const [amendmentStatus, setStatus] = React.useState<Amendment | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const { useTranslate } = useThemeHooks();
const { translate } = useTranslate();
const link = () => <Link to={`/resources/known-amendments#${props.name.toLowerCase()}`}>{props.name}{ props.compact ? "" : " amendment"}</Link>
React.useEffect(() => {
const loadAmendment = async () => {
try {
setLoading(true);
// 1. Try cache first
const cached = readAmendmentsCache();
if (cached) {
const found = cached.find(a => a.name === props.name);
if (found) {
setStatus(found);
return; // amendment successfully found in cache
}
}
// 2. New API request for stale/missing cache.
// Also catches edge case of new amendment appearing
// on mainnet within cache TTL window.
const response = await fetch(amendmentsEndpoint);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: AmendmentsResponse = await response.json();
writeAmendmentsCache(data.amendments);
const found = data.amendments.find(a => a.name === props.name);
if (!found) {
throw new Error(`Couldn't find ${props.name} amendment in status table.`);
}
setStatus(found);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch amendments');
} finally {
setLoading(false);
}
};
loadAmendment();
}, [props.name]);
if (loading) {
return (
<p><em>
{translate("component.amendment-status.requires.1", "Requires the ")}{link()}{translate("component.amendment-status.requires.2", ".")}
{" "}
<span className="spinner-border text-primary" role="status">
<span className="sr-only">{translate("amendment.loading_status", "Loading...")}</span>
</span>
</em></p>
)
}
if (error) {
return (
<p><em>
{translate("component.amendment-status.requires.1", "Requires the ")}{link()}{translate("component.amendment-status.requires.2", ".")}
{" "}
<span className="alert alert-danger" style={{display: "block"}}>
<strong>{translate("amendment.error_status", "Error loading amendment status")}:</strong> {error}
</span>
</em></p>
)
}
if (props.compact) {
return (
<>
{link()}
{" "}
<AmendmentBadge amendment={amendmentStatus} />
</>
)
}
return (
<p><em>(
{
amendmentStatus.date ? (
<>
{translate("component.amendment-status.added.1", "Added by the ")}{link()}
{translate("component.amendment-status.added.2", ".")}
{" "}
<AmendmentBadge amendment={amendmentStatus} />
</>
) : (
<>
{translate("component.amendment-status.requires.1", "Requires the ")}{link()}
{translate("component.amendment-status.requires.2", ".")}
{" "}
<AmendmentBadge amendment={amendmentStatus} />
</>
)
}
)</em></p>
)
}
function shieldsIoEscape(s: string) {
return s.trim()
.replace(/-/g, '--')
.replace(/_/g, '__')
.replace(/%/g, '%25')
}
export function Badge(props: {
children: React.ReactNode;
color: string;
href: string;
}) {
const DEFAULT_COLORS = {
"open for voting": "80d0e0",
"投票中": "80d0e0", // ja: open for voting
"expected": "blue",
"予定": "blue", // ja: expected
"enabled": "green",
"有効": "green", // ja: enabled
"obsolete": "red",
"removed in": "red",
"削除": "red", // ja: removed in
"廃止": "red", // ja: obsolete
"撤回": "red", // ja: withdrawn/removed/vetoed
"new in": "blue",
"新規": "blue", // ja: new in
"updated in": "blue",
"更新": "blue", // ja: updated in
"in development": "lightgrey",
"開発中": "lightgrey", // ja: in development
}
let childstrings = ""
React.Children.forEach(props.children, (child, index) => {
if (typeof child == "string") {
childstrings += child
}
});
const parts = childstrings.split(":")
const left : string = shieldsIoEscape(parts[0])
const right : string = shieldsIoEscape(parts.slice(1).join(":"))
let color = props.color
if (!color) {
if (DEFAULT_COLORS.hasOwnProperty(left.toLowerCase())) {
color = DEFAULT_COLORS[left.toLowerCase()]
} else {
color = "lightgrey"
}
}
let badge_url = `https://img.shields.io/badge/${left}-${right}-${color}.svg`
if (props.href) {
return (
<Link to={props.href}>
<img src={badge_url} alt={childstrings} className="shield" />
</Link>
)
} else {
return (
<img src={badge_url} alt={childstrings} className="shield" />
)
}
}

View File

@@ -3,13 +3,13 @@ import { useLocation } from 'react-router-dom';
// @ts-ignore
import dynamicReact from '@markdoc/markdoc/dist/react';
import { Link } from '@redocly/theme/components/Link/Link';
import { useThemeHooks } from '@redocly/theme/core/hooks'
import { useThemeHooks } from '@redocly/theme/core/hooks';
import { idify } from '../helpers';
import { Button } from '@redocly/theme/components/Button/Button';
export {default as XRPLoader} from '../components/XRPLoader';
export { XRPLCard, CardGrid } from '../components/XRPLCard';
export * from '../components/Amendments';
export function IndexPageItems() {
const { usePageSharedData } = useThemeHooks();
@@ -89,67 +89,6 @@ export function CodePageName(props: {
)
}
export function Badge(props: {
children: React.ReactNode;
color: string;
href: string;
}) {
const DEFAULT_COLORS = {
"open for voting": "80d0e0",
"投票中": "80d0e0", // ja: open for voting
"expected": "blue",
"予定": "blue", // ja: expected
"enabled": "green",
"有効": "green", // ja: enabled
"obsolete": "red",
"removed in": "red",
"削除": "red", // ja: removed in
"廃止": "red", // ja: obsolete
"撤回": "red", // ja: withdrawn/removed/vetoed
"new in": "blue",
"新規": "blue", // ja: new in
"updated in": "blue",
"更新": "blue", // ja: updated in
"in development": "lightgrey",
"開発中": "lightgrey", // ja: in development
}
let childstrings = ""
React.Children.forEach(props.children, (child, index) => {
if (typeof child == "string") {
childstrings += child
}
});
const parts = childstrings.split(":")
const left : string = shieldsIoEscape(parts[0])
const right : string = shieldsIoEscape(parts.slice(1).join(":"))
let color = props.color
if (!color) {
if (DEFAULT_COLORS.hasOwnProperty(left.toLowerCase())) {
color = DEFAULT_COLORS[left.toLowerCase()]
} else {
color = "lightgrey"
}
}
let badge_url = `https://img.shields.io/badge/${left}-${right}-${color}.svg`
if (props.href) {
return (
<Link to={props.href}>
<img src={badge_url} alt={childstrings} className="shield" />
</Link>
)
} else {
return (
<img src={badge_url} alt={childstrings} className="shield" />
)
}
}
type TryItServer = 's1' | 's2' | 'xrplcluster' | 'testnet' | 'devnet' | 'testnet-clio' | 'devnet-clio'
export function TryIt(props: {
@@ -206,13 +145,6 @@ export function TxExample(props: {
)
}
function shieldsIoEscape(s: string) {
return s.trim()
.replace(/-/g, '--')
.replace(/_/g, '__')
.replace(/%/g, '%25')
}
export function NotEnabled() {
const { useTranslate } = useThemeHooks();
const { translate } = useTranslate();
@@ -220,278 +152,3 @@ export function NotEnabled() {
<span className="status not_enabled" title={translate("This feature is not currently enabled on the production XRP Ledger.")}><i className="fa fa-flask"></i></span>
)
}
// Type definitions for amendments
type Amendment = {
name: string;
rippled_version: string;
tx_hash?: string;
consensus?: string;
date?: string;
id: string;
eta?: string;
};
type AmendmentsResponse = {
amendments: Amendment[];
};
// Generate amendments table with live mainnet data
export function AmendmentsTable() {
const [amendments, setAmendments] = React.useState<Amendment[]>([]);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const { useTranslate } = useThemeHooks();
const { translate } = useTranslate();
React.useEffect(() => {
const fetchAmendments = async () => {
try {
setLoading(true);
const response = await fetch(`https://vhs.prod.ripplex.io/v1/network/amendments/vote/main/`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: AmendmentsResponse = await response.json();
// Sort amendments table
const sortedAmendments = data.amendments
.sort((a: Amendment, b: Amendment) => {
// Sort by status priority (lower number = higher priority)
const getStatusPriority = (amendment: Amendment): number => {
if (amendment.eta) return 0; // Expected
if (amendment.consensus) return 1; // Open for Voting
if (amendment.tx_hash) return 2; // Enabled
};
const priorityA = getStatusPriority(a);
const priorityB = getStatusPriority(b);
if (priorityA !== priorityB) {
return priorityA - priorityB;
}
// Sort by rippled_version (descending)
if (a.rippled_version !== b.rippled_version) {
return b.rippled_version.localeCompare(a.rippled_version, undefined, { numeric: true });
}
// Finally sort by name (ascending)
return a.name.localeCompare(b.name);
});
setAmendments(sortedAmendments);
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch amendments');
} finally {
setLoading(false);
}
};
fetchAmendments();
}, []);
// Fancy schmancy loading icon
if (loading) {
return (
<div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', padding: '2rem' }}>
<div className="spinner-border text-primary" role="status">
<span className="sr-only">{translate("amendment.loading", "Loading amendments...")}</span>
</div>
<div style={{ marginTop: '1rem' }}>{translate("amendment.loading", "Loading amendments...")}</div>
</div>
);
}
if (error) {
return (
<div className="alert alert-danger" role="alert">
<strong>{translate("amendment.error", "Error loading amendments:")}:</strong> {error}
</div>
);
}
// Render amendment table
return (
<div className="md-table-wrapper">
<table className="md">
<thead>
<tr>
<th align="left" data-label="Name">{translate("amendment.table.name", "Name")}</th>
<th align="left" data-label="Introduced">{translate("amendment.table.introduced", "Introduced")}</th>
<th align="left" data-label="Status">{translate("amendment.table.status", "Status")}</th>
</tr>
</thead>
<tbody>
{amendments.map((amendment) => (
<tr key={amendment.id}>
<td align="left">
<Link to={`#${amendment.name.toLowerCase()}`}>{amendment.name}</Link>
</td>
<td align="left">{amendment.rippled_version}</td>
<td align="left">
<AmendmentBadge amendment={amendment} />
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function AmendmentBadge(props: { amendment: Amendment }) {
const [status, setStatus] = React.useState<string>('Loading...');
const [href, setHref] = React.useState<string | undefined>(undefined);
const [color, setColor] = React.useState<string>('blue');
const { useTranslate } = useThemeHooks();
const { translate } = useTranslate();
const enabledLabel = translate("amendment.status.enabled", "Enabled");
const votingLabel = translate("amendment.status.openForVoting", "Open for Voting");
const etaLabel = translate("amendment.status.eta", "Expected");
React.useEffect(() => {
const amendment = props.amendment;
// Check if amendment is enabled (has tx_hash)
if (amendment.tx_hash) {
const enabledDate = new Date(amendment.date).toISOString().split('T')[0];
setStatus(`${enabledLabel}: ${enabledDate}`);
setColor('green');
setHref(`https://livenet.xrpl.org/transactions/${amendment.tx_hash}`);
}
// Check if expected activation is provided (has eta field)
else if (amendment.eta) {
let etaDate = new Date(amendment.eta).toISOString().split('T')[0];
setStatus(`${etaLabel}: ${etaDate}`);
setColor('blue');
setHref(undefined);
}
// Check if amendment is currently being voted on (has consensus field)
else if (amendment.consensus) {
setStatus(`${votingLabel}: ${amendment.consensus}`);
setColor('80d0e0');
setHref(undefined); // No link for voting amendments
}
}, [props.amendment, enabledLabel, etaLabel, votingLabel]);
// Split the status at the colon to create two-color badge
const parts = status.split(':');
const label = shieldsIoEscape(parts[0]);
const message = shieldsIoEscape(parts.slice(1).join(':'));
const badgeUrl = `https://img.shields.io/badge/${label}-${message}-${color}`;
if (href) {
return (
<Link to={href} target="_blank">
<img src={badgeUrl} alt={status} className="shield" />
</Link>
);
}
return <img src={badgeUrl} alt={status} className="shield" />;
}
export function AmendmentDisclaimer(props: {
name: string,
compact: boolean
}) {
const [amendmentStatus, setStatus] = React.useState<Amendment | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
const { useTranslate } = useThemeHooks();
const { translate } = useTranslate();
const link = () => <Link to={`/resources/known-amendments#${props.name.toLowerCase()}`}>{props.name}{ props.compact ? "" : " amendment"}</Link>
React.useEffect(() => {
const fetchAmendments = async () => {
try {
setLoading(true);
const response = await fetch(`https://vhs.prod.ripplex.io/v1/network/amendments/vote/main/`);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data: AmendmentsResponse = await response.json()
console.log("data.amendments is:", data.amendments)
let found_amendment = false
for (const amendment of data.amendments) {
if (amendment.name == props.name) {
setStatus(amendment)
found_amendment = true
break
}
}
if (!found_amendment) {
throw new Error(`Couldn't find ${props.name} amendment in status table.`)
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to fetch amendments');
} finally {
setLoading(false)
}
}
fetchAmendments()
}, [])
if (loading) {
return (
<p><em>
{translate("component.amendment-status.requires.1", "Requires the ")}{link()}{translate("component.amendment-status.requires.2", ".")}
{" "}
<span className="spinner-border text-primary" role="status">
<span className="sr-only">{translate("amendment.loading_status", "Loading...")}</span>
</span>
</em></p>
)
}
if (error) {
return (
<p><em>
{translate("component.amendment-status.requires.1", "Requires the ")}{link()}{translate("component.amendment-status.requires.2", ".")}
{" "}
<span className="alert alert-danger" style={{display: "block"}}>
<strong>{translate("amendment.error_status", "Error loading amendment status")}:</strong> {error}
</span>
</em></p>
)
}
if (props.compact) {
return (
<>
{link()}
{" "}
<AmendmentBadge amendment={amendmentStatus} />
</>
)
}
return (
<p><em>(
{
amendmentStatus.date ? (
<>
{translate("component.amendment-status.added.1", "Added by the ")}{link()}
{translate("component.amendment-status.added.2", ".")}
{" "}
<AmendmentBadge amendment={amendmentStatus} />
</>
) : (
<>
{translate("component.amendment-status.requires.1", "Requires the ")}{link()}
{translate("component.amendment-status.requires.2", ".")}
{" "}
<AmendmentBadge amendment={amendmentStatus} />
</>
)
}
)</em></p>
)
}