Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@ jobs:
if: matrix.node == '22'
run: npx playwright install --with-deps chromium

- name: Check recovery-alert browser flow and small windows
- name: Check recovery and dashboard browser flows and small windows
if: matrix.node == '22'
run: npm run test:recovery-browser
run: npx playwright test --config playwright.config.cjs tests/browser/recovery-alerts.spec.cjs tests/browser/network-availability.spec.cjs

- name: Preserve recovery-alert screenshots and failure traces
if: always() && matrix.node == '22'
Expand Down
22 changes: 22 additions & 0 deletions src/App.css
Original file line number Diff line number Diff line change
Expand Up @@ -736,6 +736,13 @@

.chart-canvas {
height: 360px;
min-width: 0;
}

.panel--chart,
.breakdown-panel {
/* Canvas has an intrinsic 300px width; let its grid track shrink on phones. */
min-width: 0;
}

.range-switcher,
Expand Down Expand Up @@ -1436,6 +1443,21 @@
color: #855b15;
}

.network-data-status {
display: grid;
gap: 10px;
overflow-wrap: anywhere;
}

.network-data-status .notice {
margin: 0;
}

.metric-card strong {
white-space: normal;
overflow-wrap: anywhere;
}

.state-block {
text-align: center;
background: linear-gradient(180deg, rgba(249, 252, 255, 0.98) 0%, rgba(244, 248, 252, 0.98) 100%);
Expand Down
28 changes: 28 additions & 0 deletions src/components/NetworkDataStatus.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import React from 'react';

const LABELS = { chain: 'Chain', node: 'Sentry node', governance: 'Governance', market: 'Market' };

export default function NetworkDataStatus({ groups, historyError }) {
const observations = Object.entries(groups || {});
// The page already has one clear outage message when every live group is
// unavailable. Do not repeat it in four more warning panels.
const allUnavailable = observations.length > 0 && observations.every(([, group]) => group.status === 'unavailable');
const notices = new Map();
for (const [name, group] of observations) {
if (allUnavailable || group.status === 'ready') continue;
const key = `${group.status}:${group.updatedAt || ''}`;
const notice = notices.get(key) || { ...group, names: [] };
notice.names.push(LABELS[name]);
notices.set(key, notice);
}
return <div className="network-data-status" aria-live="polite">
{[...notices.entries()].map(([key, group]) => (
<p className="notice notice--warning" key={key}>
{group.names.join(', ')} statistics {group.status === 'stale' ? 'are stale' :
group.status === 'unverified' ? 'have no source freshness timestamp' : 'are unavailable'}.
{group.updatedAt ? ` Last successful update: ${new Date(group.updatedAt).toLocaleString()}.` : ''}
</p>
))}
{historyError ? <p className="notice notice--warning">{historyError}</p> : null}
</div>;
}
45 changes: 31 additions & 14 deletions src/hooks/useGovernanceData.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { useCallback, useEffect, useRef, useState } from 'react';

import { fetchGovernanceFeed, fetchNetworkStats } from '../lib/api';
import { isSupportedGovernanceProposal } from '../lib/governanceEligibility';
import { normalizeNetworkStats } from '../lib/networkStats';
import { useBackgroundPoll } from './useBackgroundPoll';

// Governance feed + network stats, loaded once on mount and refetchable
// on demand via the returned `refresh` callback.
Expand Down Expand Up @@ -40,29 +42,36 @@ export default function useGovernanceData() {
loadedAt: null,
loading: true,
proposals: [],
proposalsLoaded: false,
stats: null,
groups: {},
});
const mountedRef = useRef(true);
const latestRequestIdRef = useRef(0);

const load = useCallback(async function load() {
if (!mountedRef.current) return;
const requestId = latestRequestIdRef.current + 1;
latestRequestIdRef.current = requestId;
// Keep the previous proposals/stats visible while the refetch
// is in flight — toggling the list to empty mid-refresh would
// cause jarring layout shifts. Mark `loading` so UI surfaces
// can show a spinner overlay.
setState(function markLoading(prev) {
if (!mountedRef.current || latestRequestIdRef.current !== requestId) return prev;
return { ...prev, loading: true, error: '' };
});
try {
const response = await Promise.all([
const response = await Promise.allSettled([
fetchNetworkStats(),
fetchGovernanceFeed(),
]);
if (!mountedRef.current) return;
if (latestRequestIdRef.current !== requestId) return;
const rawProposals = Array.isArray(response[1]) ? response[1] : [];
const feedSucceeded = response[1].status === 'fulfilled' && Array.isArray(response[1].value);
const normalized = response[0].status === 'fulfilled'
? normalizeNetworkStats(response[0].value) : null;
const rawProposals = feedSucceeded ? response[1].value : [];
const nowMs = Date.now();
const proposals = rawProposals
.filter(function keepProposal(proposal) {
Expand All @@ -73,23 +82,29 @@ export default function useGovernanceData() {
Number(b.AbsoluteYesCount || 0) - Number(a.AbsoluteYesCount || 0)
);
});
setState({
error: '',
loadedAt: new Date(),
setState((previous) => {
if (!mountedRef.current || latestRequestIdRef.current !== requestId) return previous;
return {
error: [!normalized?.stats ? 'Live network statistics are unavailable right now.' : '',
!feedSucceeded ? 'Unable to refresh the governance proposal feed.' : ''].filter(Boolean).join(' '),
loadedAt: normalized?.stats ? normalized.loadedAt : previous.loadedAt,
loading: false,
proposals,
stats: response[0],
proposals: feedSucceeded ? proposals : previous.proposals,
proposalsLoaded: feedSucceeded || previous.proposalsLoaded,
stats: normalized?.stats || previous.stats,
groups: normalized?.stats ? normalized.groups : Object.fromEntries(
Object.entries(previous.groups).map(([name, group]) => [name,
group.status === 'unavailable' ? group : { ...group, status: 'stale' }]))
};
});
} catch (err) {
if (!mountedRef.current) return;
if (latestRequestIdRef.current !== requestId) return;
setState({
error: 'Unable to load governance data right now.',
loadedAt: new Date(),
loading: false,
proposals: [],
stats: null,
});
setState((previous) => !mountedRef.current || latestRequestIdRef.current !== requestId ? previous : ({
...previous,
error: 'Unable to load governance data right now.',
loading: false,
}));
}
}, []);

Expand All @@ -104,5 +119,7 @@ export default function useGovernanceData() {
[load]
);

useBackgroundPoll(load, { enabled: true, intervalMs: 60000 });

return { ...state, refresh: load };
}
18 changes: 18 additions & 0 deletions src/hooks/useGovernanceData.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ function Probe() {
return <output data-testid="proposals">{state.loading ? 'loading' : JSON.stringify(state.proposals.map((p) => p.Key))}</output>;
}

function SnapshotProbe() {
const state = useGovernanceData();
return <output data-testid="snapshot">{JSON.stringify(state)}</output>;
}

describe('governance feed eligibility', () => {
beforeEach(() => {
jest.spyOn(Date, 'now').mockReturnValue(NOW);
Expand Down Expand Up @@ -74,4 +79,17 @@ describe('governance feed eligibility', () => {
render(<Probe />);
await waitFor(() => expect(screen.getByTestId('proposals').textContent).toBe(JSON.stringify(['a'.repeat(64), 'c'.repeat(64)])));
});

test('a statistics outage does not discard a valid independent proposal feed', async () => {
fetchNetworkStats.mockRejectedValue(new Error('503'));
fetchGovernanceFeed.mockResolvedValue([proposal()]);
render(<SnapshotProbe />);
await waitFor(() => expect(JSON.parse(screen.getByTestId('snapshot').textContent).loading).toBe(false));
const state = JSON.parse(screen.getByTestId('snapshot').textContent);
expect(state.proposals).toHaveLength(1);
expect(state.proposalsLoaded).toBe(true);
expect(state.stats).toBeNull();
expect(state.loadedAt).toBeNull();
expect(state.error).toMatch(/statistics are unavailable/);
});
});
85 changes: 50 additions & 35 deletions src/hooks/useNetworkData.js
Original file line number Diff line number Diff line change
@@ -1,55 +1,70 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';

import { fetchNetworkStats, fetchNodeHistory } from '../lib/api';
import { normalizeNetworkStats, normalizeNodeHistory } from '../lib/networkStats';
import { useBackgroundPoll } from './useBackgroundPoll';

export default function useNetworkData() {
const [state, setState] = useState({
loading: true,
statsLoading: true,
historyLoading: true,
error: '',
historyError: '',
groups: {},
history: [],
loadedAt: null,
stats: null,
});
const mounted = useRef(true);
const generation = useRef(0);

useEffect(function loadNetworkData() {
let cancelled = false;

async function load() {
try {
const response = await Promise.all([fetchNetworkStats(), fetchNodeHistory()]);

if (cancelled) {
return;
}

setState({
loading: false,
error: '',
history: Array.isArray(response[1]) ? response[1] : [],
loadedAt: new Date(),
stats: response[0],
});
} catch (error) {
if (cancelled) {
return;
}

setState({
loading: false,
error: 'Unable to load the latest network metrics right now.',
history: [],
loadedAt: new Date(),
stats: null,
});
}
const load = useCallback(() => {
const request = ++generation.current;
function update(fields) {
if (!mounted.current || request !== generation.current) return;
setState((previous) => {
if (!mounted.current || request !== generation.current) return previous;
const next = { ...previous, ...(typeof fields === 'function' ? fields(previous) : fields) };
return { ...next, loading: next.statsLoading || next.historyLoading };
});
}
update({ statsLoading: true, historyLoading: true });
function statsFailure(previous) {
return {
statsLoading: false,
// Keep a known observation visible, explicitly stale, without changing
// its source timestamp. An outage is never a successful zero snapshot.
groups: Object.fromEntries(Object.entries(previous.groups).map(([key, group]) =>
[key, group.status === 'unavailable' ? group : { ...group, status: 'stale' }])),
error: previous.stats
? 'Unable to refresh live statistics. Showing the last known observations.'
: 'Live network statistics are unavailable right now.',
};
}
const statsRequest = fetchNetworkStats().then((payload) => {
const normalized = normalizeNetworkStats(payload);
if (!normalized.stats) update(statsFailure);
else update({ ...normalized, statsLoading: false, error: '' });
}).catch(() => update(statsFailure));
const historyRequest = fetchNodeHistory().then((history) => update({ historyLoading: false,
history: normalizeNodeHistory(history),
historyError: Array.isArray(history) ? '' : 'Node history is unavailable right now.'
})).catch(() => update({ historyLoading: false,
historyError: 'Node history is unavailable right now.' }));
return Promise.all([statsRequest, historyRequest]);
}, []);

useEffect(function loadNetworkData() {
mounted.current = true;
load();

return function cleanup() {
cancelled = true;
mounted.current = false;
generation.current += 1;
};
}, []);
}, [load]);

useBackgroundPoll(load, { enabled: true, intervalMs: 60000 });

return state;
}
Loading
Loading