diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2f66ef0..5f3a982 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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' diff --git a/src/App.css b/src/App.css index 38a66d3..c49613e 100644 --- a/src/App.css +++ b/src/App.css @@ -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, @@ -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%); diff --git a/src/components/NetworkDataStatus.js b/src/components/NetworkDataStatus.js new file mode 100644 index 0000000..d62f4b5 --- /dev/null +++ b/src/components/NetworkDataStatus.js @@ -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
+ {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()}.` : ''} +
+ ))} + {historyError ?{historyError}
: null} +{JSON.stringify(data)};
+}
+const state = () => JSON.parse(screen.getByTestId('state').textContent);
+beforeEach(() => jest.clearAllMocks());
+afterEach(() => {
+ cleanup();
+ jest.useRealTimers();
+ Object.defineProperty(document, 'hidden', { configurable: true, value: false });
+});
+async function settle() {
+ await act(async () => { for (let i = 0; i < 6; i += 1) await Promise.resolve(); });
+}
+function hidden(value) {
+ Object.defineProperty(document, 'hidden', { configurable: true, value });
+ document.dispatchEvent(new Event('visibilitychange'));
+}
+
+test('history succeeds independently while current statistics fail', async () => {
+ fetchNetworkStats.mockRejectedValue(new Error('503'));
+ fetchNodeHistory.mockResolvedValue([{ date: '2026-10-21', users: 3123 }]);
+ render(