From b5512e2054865109301fb96f9e492c511e2fdca2 Mon Sep 17 00:00:00 2001 From: Demilade10 Date: Fri, 28 Aug 2026 14:33:25 +0100 Subject: [PATCH 1/2] feat: standardize badge styling (#504) and add keyboard shortcuts (#505) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #504 — Improve Notification Badge Styling - Add 14 badge design tokens (--badge-*) in :root and light theme - Update 15 badge class families to use var() token references - Standardize padding, border-radius, font-size, font-weight across all badges - Ensure light theme badge colors have sufficient contrast - Add 17 tests verifying token definitions, var() usage, and class mappings #505 — Support Keyboard Shortcuts - Add useKeyboardShortcuts hook: 1-9 tab switching, T theme, R refresh, ? help - Add KeyboardShortcutsHelp component with ARIA dialog, focus management - Integrate shortcuts into App.tsx with hints on tabs - Disable shortcuts while typing in inputs/editable elements - Avoid conflicts with browser/OS modifier key shortcuts - Add 13 tests for shortcut behavior, accessibility, and help overlay - Document shortcuts in STORYBOOK.md --- dashboard/STORYBOOK.md | 17 + dashboard/src/App.tsx | 252 +-------- dashboard/src/components/Badge.test.tsx | 177 ++++++ .../src/components/KeyboardShortcutsHelp.tsx | 102 ++++ .../src/hooks/useKeyboardShortcuts.test.tsx | 208 +++++++ dashboard/src/hooks/useKeyboardShortcuts.ts | 99 ++++ dashboard/src/index.css | 506 +++++++++++++----- 7 files changed, 1002 insertions(+), 359 deletions(-) create mode 100644 dashboard/src/components/Badge.test.tsx create mode 100644 dashboard/src/components/KeyboardShortcutsHelp.tsx create mode 100644 dashboard/src/hooks/useKeyboardShortcuts.test.tsx create mode 100644 dashboard/src/hooks/useKeyboardShortcuts.ts diff --git a/dashboard/STORYBOOK.md b/dashboard/STORYBOOK.md index 233aa5c8..c459cc9d 100644 --- a/dashboard/STORYBOOK.md +++ b/dashboard/STORYBOOK.md @@ -30,3 +30,20 @@ npm run build-storybook - `WebhookSummaryCards` — loading and filled metrics Stories live next to components as `*.stories.tsx` and share fixtures under `src/stories/fixtures/`. + +## Keyboard Shortcuts + +The dashboard supports keyboard shortcuts for faster navigation. Press `?` at any time to open the shortcuts help overlay. + +| Shortcut | Action | +|----------|--------| +| `1`-`9` | Switch to the corresponding tab (Event Explorer, Delivery Timeline, etc.) | +| `T` | Toggle between dark and light theme | +| `R` | Refresh events | +| `?` | Show/hide keyboard shortcuts help | +| `Esc` | Close dialogs and overlays | + +**Accessibility notes:** +- Shortcuts are disabled while typing in input fields, textareas, or editable elements. +- Modifier keys (Ctrl, Alt, Meta) are respected to avoid conflicts with browser and OS shortcuts. +- The shortcuts help overlay follows ARIA dialog patterns with focus management. diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index 948d00ec..c58f3386 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -1,5 +1,3 @@ -import { useState } from 'react'; -import { useState, useRef, useEffect } from 'react'; /** * App.tsx * @@ -9,6 +7,7 @@ import { useState, useRef, useEffect } from 'react'; * - #396 Navigation Redesign: grouped tabs, active-route highlighting, * mobile hamburger + off-canvas drawer * - #397 Toast: ToastProvider wraps the whole app + * - #505 Keyboard Shortcuts: number keys for tabs, T for theme, ? for help */ import { useState, useRef, useCallback, type KeyboardEvent } from 'react'; @@ -30,229 +29,28 @@ import { useTheme } from './hooks/useTheme'; import { DeliveryHeatmap } from './components/DeliveryHeatmap'; import { useEventStore } from './store/eventStore'; import { SyncStatus } from './components/SyncStatus'; - -type Tab = 'explorer' | 'preferences'; - -export function App() { - const [activeTab, setActiveTab] = useState('explorer'); -type Tab = - | 'explorer' - | 'timeline' - | 'activity' - | 'webhooks' - | 'export-history' - | 'search' - | 'preferences' - | 'templates' - | 'channels'; - -const TAB_ITEMS: { id: Tab; label: string }[] = [ - { id: 'explorer', label: 'Event Explorer' }, - { id: 'timeline', label: 'Delivery Timeline' }, - { id: 'activity', label: 'Activity Feed' }, - { id: 'webhooks', label: 'Webhook Performance' }, - { id: 'export-history', label: 'Export History' }, - { id: 'search', label: 'Notification Search' }, - { id: 'preferences', label: 'Preferences' }, - { id: 'templates', label: 'Templates' }, -]; +import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'; +import { KeyboardShortcutsHelp } from './components/KeyboardShortcutsHelp'; -export function App() { - const [tab, setTab] = useState('explorer'); - const [menuOpen, setMenuOpen] = useState(false); - const { theme, toggleTheme } = useTheme(); - const events = useEventStore((state) => state.events); - const menuRef = useRef(null); - - useEffect(() => { - function handleClickOutside(event: MouseEvent) { - if (menuRef.current && !menuRef.current.contains(event.target as Node)) { - setMenuOpen(false); - } - } - - function handleEscape(event: KeyboardEvent) { - if (event.key === 'Escape') { - setMenuOpen(false); - } - } - - if (menuOpen) { - document.addEventListener('mousedown', handleClickOutside); - document.addEventListener('keydown', handleEscape); - } - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - document.removeEventListener('keydown', handleEscape); - }; - }, [menuOpen]); - - const handleTabChange = (newTab: Tab) => { - setTab(newTab); - setMenuOpen(false); - }; - - return ( -
-
-
-

Notify Chain

-

{activeTab === 'preferences' ? 'Notification Preferences' : 'Event Explorer'}

-
- - - - - {activeTab === 'explorer' ? : } -
-

NotifyChain

-

Dashboard

-
-
- -
- - -
-
- - - - {tab === 'explorer' && ( export function App() { const [tab, setTab] = useState('explorer'); const [drawerOpen, setDrawerOpen] = useState(false); + const [helpOpen, setHelpOpen] = useState(false); const { theme, toggleTheme } = useTheme(); const events = useEventStore((state) => state.events); const tabListRef = useRef(null); const hamburgerRef = useRef(null); + // ── Keyboard shortcuts (#505) ─────────────────────────────────────────── + useKeyboardShortcuts({ + activeTab: tab, + onTabChange: setTab, + onToggleTheme: toggleTheme, + helpOpen, + onToggleHelp: useCallback(() => setHelpOpen((prev) => !prev), []), + onCloseHelp: useCallback(() => setHelpOpen(false), []), + }); + // ── Keyboard navigation inside tablist (arrow keys) ────────────────────── const handleTabKeyDown = useCallback( (e: KeyboardEvent) => { @@ -296,6 +94,9 @@ export function App() { Skip to main content + {/* Keyboard shortcuts help overlay (#505) */} + setHelpOpen(false)} /> +
{/* Top bar */}
@@ -333,7 +134,7 @@ export function App() { className="app-tabs__list" onKeyDown={handleTabKeyDown} > - {NAV_ITEMS.map((item) => ( + {NAV_ITEMS.map((item, index) => ( ))} @@ -393,17 +198,6 @@ function renderPanel(tab: Tab, events: any[]) { - )} - {tab === 'timeline' && } - {tab === 'activity' && } - {tab === 'webhooks' && } - {tab === 'export-history' && } - {tab === 'search' && } - {tab === 'preferences' && } - {tab === 'templates' && } - {tab === 'channels' && } -
- ); ); case 'timeline': return ; @@ -423,6 +217,8 @@ function renderPanel(tab: Tab, events: any[]) { return ; case 'templates': return ; + case 'channels': + return ; default: return null; } diff --git a/dashboard/src/components/Badge.test.tsx b/dashboard/src/components/Badge.test.tsx new file mode 100644 index 00000000..a68c42b6 --- /dev/null +++ b/dashboard/src/components/Badge.test.tsx @@ -0,0 +1,177 @@ +/** + * Badge styling standardization tests (#504) + * + * Verifies that all badge variants use consistent design tokens. + * Since jsdom doesn't compute CSS custom properties, we validate + * the token definitions exist and that components apply the correct + * badge class names. + */ +import * as fs from 'fs'; +import * as path from 'path'; +import { render } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { EventCard } from './EventCard'; +import type { BlockchainEvent } from '../types/event'; + +const mockEvent: BlockchainEvent = { + eventId: 'evt-1', + type: 'TaskCreated', + eventName: 'TaskCreated', + ledger: 12345, + contractAddress: 'GABCDEF1234567890ABCDEF1234567890ABCDEF12', + receivedAt: Date.now(), + value: '100', + txHash: 'abcdef1234567890', + topic: [], +} as BlockchainEvent; + +function readCssContent(): string { + return fs.readFileSync( + path.resolve(__dirname, '../index.css'), + 'utf-8', + ); +} + +// ─── Token definition tests ───────────────────────────────────────────────── + +describe('Badge design tokens defined (#504)', () => { + it('badge design tokens are defined in CSS', () => { + const cssContent = readCssContent(); + + // Verify core badge tokens exist + expect(cssContent).toContain('--badge-padding-y:'); + expect(cssContent).toContain('--badge-padding-x:'); + expect(cssContent).toContain('--badge-radius:'); + expect(cssContent).toContain('--badge-font-size:'); + expect(cssContent).toContain('--badge-font-weight:'); + expect(cssContent).toContain('--badge-line-height:'); + + // Verify color tokens exist + expect(cssContent).toContain('--badge-green-bg:'); + expect(cssContent).toContain('--badge-green-fg:'); + expect(cssContent).toContain('--badge-blue-bg:'); + expect(cssContent).toContain('--badge-blue-fg:'); + expect(cssContent).toContain('--badge-red-bg:'); + expect(cssContent).toContain('--badge-red-fg:'); + expect(cssContent).toContain('--badge-yellow-bg:'); + expect(cssContent).toContain('--badge-yellow-fg:'); + expect(cssContent).toContain('--badge-purple-bg:'); + expect(cssContent).toContain('--badge-purple-fg:'); + expect(cssContent).toContain('--badge-orange-bg:'); + expect(cssContent).toContain('--badge-orange-fg:'); + expect(cssContent).toContain('--badge-neutral-bg:'); + expect(cssContent).toContain('--badge-neutral-fg:'); + }); + + it('all badge classes use var() references to tokens', () => { + const cssContent = readCssContent(); + + // Verify key badge classes reference tokens + expect(cssContent).toMatch(/\.event-card__badge\s*\{[^}]*var\(--badge-/); + expect(cssContent).toMatch(/\.event-card__badge--green\s*\{[^}]*var\(--badge-green-bg\)/); + expect(cssContent).toMatch(/\.event-card__badge--blue\s*\{[^}]*var\(--badge-blue-bg\)/); + expect(cssContent).toMatch(/\.event-card__badge--red\s*\{[^}]*var\(--badge-red-bg\)/); + expect(cssContent).toMatch(/\.event-card__badge--yellow\s*\{[^}]*var\(--badge-yellow-bg\)/); + expect(cssContent).toMatch(/\.event-card__badge--purple\s*\{[^}]*var\(--badge-purple-bg\)/); + expect(cssContent).toMatch(/\.event-card__badge--orange\s*\{[^}]*var\(--badge-orange-bg\)/); + expect(cssContent).toMatch(/\.event-card__badge--default\s*\{[^}]*var\(--badge-neutral-bg\)/); + }); + + it('status badges use token references', () => { + const cssContent = readCssContent(); + + expect(cssContent).toMatch(/\.status-badge\s*\{[^}]*var\(--badge-/); + expect(cssContent).toMatch(/\.status-badge--completed\s*\{[^}]*var\(--badge-green-bg\)/); + expect(cssContent).toMatch(/\.status-badge--processing\s*\{[^}]*var\(--badge-blue-bg\)/); + expect(cssContent).toMatch(/\.status-badge--failed\s*\{[^}]*var\(--badge-red-bg\)/); + }); + + it('notif result badges use token references', () => { + const cssContent = readCssContent(); + + expect(cssContent).toMatch(/\.notif-result-card__status--completed[^{]*\{[^}]*var\(--badge-green-bg\)/); + expect(cssContent).toMatch(/\.notif-result-card__status--pending[^{]*\{[^}]*var\(--badge-yellow-bg\)/); + expect(cssContent).toMatch(/\.notif-result-card__status--failed[^{]*\{[^}]*var\(--badge-red-bg\)/); + expect(cssContent).toMatch(/\.notif-result-card__status--cancelled[^{]*\{[^}]*var\(--badge-neutral-bg\)/); + }); + + it('webhook badge classes use token references', () => { + const cssContent = readCssContent(); + + expect(cssContent).toMatch(/\.webhook-failed-table__code--4xx[^{]*\{[^}]*var\(--badge-orange-bg\)/); + expect(cssContent).toMatch(/\.webhook-failed-table__code--5xx[^{]*\{[^}]*var\(--badge-red-bg\)/); + expect(cssContent).toMatch(/\.webhook-failed-table__category--4xx[^{]*\{[^}]*var\(--badge-orange-bg\)/); + }); +}); + +// ─── Component badge class tests ──────────────────────────────────────────── + +describe('EventCard badge classes (#504)', () => { + const EVENT_TYPE_COLORS: Record = { + TaskCreated: 'event-card__badge--green', + WorkSubmitted: 'event-card__badge--blue', + SubmissionApproved: 'event-card__badge--green', + SubmissionRejected: 'event-card__badge--red', + TaskCancelled: 'event-card__badge--red', + DisputeRaised: 'event-card__badge--yellow', + AutoshareCreated: 'event-card__badge--purple', + Withdrawal: 'event-card__badge--orange', + }; + + it.each(Object.entries(EVENT_TYPE_COLORS))( + 'event type "%s" maps to badge class "%s"', + (eventType, expectedClass) => { + const { container } = render( + , + ); + const badge = container.querySelector('.event-card__badge'); + expect(badge).toHaveClass(expectedClass); + }, + ); + + it('unknown event type uses default badge', () => { + const { container } = render( + , + ); + const badge = container.querySelector('.event-card__badge'); + expect(badge).toHaveClass('event-card__badge--default'); + }); + + it('null eventName uses default badge', () => { + const { container } = render( + , + ); + const badge = container.querySelector('.event-card__badge'); + expect(badge).toHaveClass('event-card__badge--default'); + }); +}); + +// ─── Accessibility contrast checks ────────────────────────────────────────── + +describe('Badge accessibility contrast (#504)', () => { + it('all badge foreground colors are defined as hex values', () => { + const cssContent = readCssContent(); + + // All foreground colors should be hex values (ensures they're not transparent) + const fgTokenPattern = /--badge-[a-z]+-fg:\s*(#[0-9a-fA-F]{3,8})/g; + const matches = [...cssContent.matchAll(fgTokenPattern)]; + expect(matches.length).toBeGreaterThanOrEqual(7); + + matches.forEach((match) => { + expect(match[1]).toMatch(/^#[0-9a-fA-F]{3,8}$/); + }); + }); + + it('light theme badge colors are defined', () => { + const cssContent = readCssContent(); + + // Light theme should override badge colors + const lightSection = cssContent.substring( + cssContent.indexOf('[data-theme="light"]'), + ); + expect(lightSection).toContain('--badge-green-bg:'); + expect(lightSection).toContain('--badge-green-fg:'); + expect(lightSection).toContain('--badge-blue-bg:'); + expect(lightSection).toContain('--badge-red-bg:'); + }); +}); diff --git a/dashboard/src/components/KeyboardShortcutsHelp.tsx b/dashboard/src/components/KeyboardShortcutsHelp.tsx new file mode 100644 index 00000000..0da851c4 --- /dev/null +++ b/dashboard/src/components/KeyboardShortcutsHelp.tsx @@ -0,0 +1,102 @@ +/** + * KeyboardShortcutsHelp — Modal overlay showing available shortcuts (#505) + * + * Displays a keyboard shortcut cheat sheet. Rendered as a dialog with + * proper ARIA attributes and focus management. + */ +import { useEffect, useRef } from 'react'; +import { NAV_ITEMS } from './MobileNavDrawer'; + +interface KeyboardShortcutsHelpProps { + isOpen: boolean; + onClose: () => void; +} + +const SHORTCUTS = [ + ...NAV_ITEMS.slice(0, 9).map((item, i) => ({ + key: String(i + 1), + label: item.label, + })), + { key: 'T', label: 'Toggle theme (dark/light)' }, + { key: 'R', label: 'Refresh events' }, + { key: '?', label: 'Show/hide this help' }, + { key: 'Esc', label: 'Close dialogs and overlays' }, +]; + +export function KeyboardShortcutsHelp({ isOpen, onClose }: KeyboardShortcutsHelpProps) { + const dialogRef = useRef(null); + const previousFocus = useRef(null); + + useEffect(() => { + if (isOpen) { + previousFocus.current = document.activeElement as HTMLElement; + document.body.style.overflow = 'hidden'; + + // Focus the dialog after render + const timer = setTimeout(() => { + dialogRef.current?.focus(); + }, 50); + + return () => clearTimeout(timer); + } else { + document.body.style.overflow = ''; + previousFocus.current?.focus(); + } + }, [isOpen]); + + // Escape key handled by useKeyboardShortcuts, but we also handle click outside + useEffect(() => { + if (!isOpen) return; + const handleClick = (e: MouseEvent) => { + if (dialogRef.current && !dialogRef.current.contains(e.target as Node)) { + onClose(); + } + }; + document.addEventListener('mousedown', handleClick); + return () => document.removeEventListener('mousedown', handleClick); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return ( + + ); +} diff --git a/dashboard/src/hooks/useKeyboardShortcuts.test.tsx b/dashboard/src/hooks/useKeyboardShortcuts.test.tsx new file mode 100644 index 00000000..65bab4f0 --- /dev/null +++ b/dashboard/src/hooks/useKeyboardShortcuts.test.tsx @@ -0,0 +1,208 @@ +/** + * Keyboard shortcuts tests (#505) + * + * Tests for the useKeyboardShortcuts hook and KeyboardShortcutsHelp component. + */ +import { useState } from 'react'; +import { render, fireEvent } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { useKeyboardShortcuts } from '../hooks/useKeyboardShortcuts'; +import { KeyboardShortcutsHelp } from '../components/KeyboardShortcutsHelp'; + +// ─── useKeyboardShortcuts hook tests ──────────────────────────────────────── + +function ShortcutTestHarness({ + onTabChange, + onToggleTheme, + onRefresh, +}: { + onTabChange: (tab: string) => void; + onToggleTheme: () => void; + onRefresh?: () => void; +}) { + const [helpOpen, setHelpOpen] = useState(false); + + useKeyboardShortcuts({ + activeTab: 'explorer', + onTabChange, + onToggleTheme, + onRefresh, + helpOpen, + onToggleHelp: () => setHelpOpen((prev) => !prev), + onCloseHelp: () => setHelpOpen(false), + }); + + return ( +
+
{helpOpen ? 'open' : 'closed'}
+ setHelpOpen(false)} /> +
+ ); +} + +describe('useKeyboardShortcuts (#505)', () => { + it('switches tab when number key 1-9 is pressed', () => { + const onTabChange = jest.fn(); + render( + , + ); + + fireEvent.keyDown(document, { key: '1' }); + expect(onTabChange).toHaveBeenCalledWith('explorer'); + + fireEvent.keyDown(document, { key: '2' }); + expect(onTabChange).toHaveBeenCalledWith('timeline'); + + fireEvent.keyDown(document, { key: '3' }); + expect(onTabChange).toHaveBeenCalledWith('activity'); + }); + + it('toggles theme when T is pressed', () => { + const onToggleTheme = jest.fn(); + render( + , + ); + + fireEvent.keyDown(document, { key: 't' }); + expect(onToggleTheme).toHaveBeenCalledTimes(1); + + fireEvent.keyDown(document, { key: 'T' }); + expect(onToggleTheme).toHaveBeenCalledTimes(2); + }); + + it('triggers refresh when R is pressed', () => { + const onRefresh = jest.fn(); + render( + , + ); + + fireEvent.keyDown(document, { key: 'r' }); + expect(onRefresh).toHaveBeenCalledTimes(1); + }); + + it('does not trigger shortcuts when typing in an input', () => { + const onTabChange = jest.fn(); + const { getByTestId } = render( +
+ + +
, + ); + + const input = getByTestId('test-input'); + input.focus(); + fireEvent.keyDown(input, { key: '1' }); + expect(onTabChange).not.toHaveBeenCalled(); + }); + + it('does not trigger shortcuts when modifier keys are held', () => { + const onTabChange = jest.fn(); + render( + , + ); + + fireEvent.keyDown(document, { key: '1', ctrlKey: true }); + expect(onTabChange).not.toHaveBeenCalled(); + + fireEvent.keyDown(document, { key: '1', altKey: true }); + expect(onTabChange).not.toHaveBeenCalled(); + + fireEvent.keyDown(document, { key: '1', metaKey: true }); + expect(onTabChange).not.toHaveBeenCalled(); + }); + + it('does not trigger shortcuts when help overlay is open', () => { + const onTabChange = jest.fn(); + const { getByTestId } = render( + , + ); + + // Open help + fireEvent.keyDown(document, { key: '?' }); + expect(getByTestId('help-status')).toHaveTextContent('open'); + + // Now number keys should not change tab + fireEvent.keyDown(document, { key: '2' }); + expect(onTabChange).not.toHaveBeenCalled(); + }); + + it('Escape closes help overlay', () => { + const { getByTestId } = render( + , + ); + + // Open help + fireEvent.keyDown(document, { key: '?' }); + expect(getByTestId('help-status')).toHaveTextContent('open'); + + // Close with Escape + fireEvent.keyDown(document, { key: 'Escape' }); + expect(getByTestId('help-status')).toHaveTextContent('closed'); + }); + + it('does nothing for number keys beyond NAV_ITEMS length', () => { + const onTabChange = jest.fn(); + render( + , + ); + + // Key 0 should not trigger tab change + fireEvent.keyDown(document, { key: '0' }); + expect(onTabChange).not.toHaveBeenCalled(); + }); +}); + +// ─── KeyboardShortcutsHelp component tests ────────────────────────────────── + +describe('KeyboardShortcutsHelp component (#505)', () => { + it('renders nothing when closed', () => { + const { container } = render( + , + ); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders the help dialog when open', () => { + const { getByLabelText } = render( + , + ); + const dialog = getByLabelText('Keyboard shortcuts'); + expect(dialog).toHaveAttribute('role', 'dialog'); + expect(dialog).toHaveAttribute('aria-modal', 'true'); + }); + + it('displays all shortcut keys', () => { + const { getByText } = render( + , + ); + + expect(getByText('Event Explorer')).toBeInTheDocument(); + expect(getByText('Toggle theme (dark/light)')).toBeInTheDocument(); + expect(getByText('Refresh events')).toBeInTheDocument(); + expect(getByText('Show/hide this help')).toBeInTheDocument(); + }); + + it('calls onClose when close button is clicked', () => { + const onClose = jest.fn(); + const { getByLabelText } = render( + , + ); + + fireEvent.click(getByLabelText('Close keyboard shortcuts')); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('has accessible structure', async () => { + const { container } = render( + , + ); + const { axe, toHaveNoViolations } = await import('jest-axe'); + expect.extend(toHaveNoViolations); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); +}); diff --git a/dashboard/src/hooks/useKeyboardShortcuts.ts b/dashboard/src/hooks/useKeyboardShortcuts.ts new file mode 100644 index 00000000..c4c3f8f6 --- /dev/null +++ b/dashboard/src/hooks/useKeyboardShortcuts.ts @@ -0,0 +1,99 @@ +/** + * useKeyboardShortcuts — Global keyboard shortcuts for the dashboard (#505) + * + * Shortcuts: + * - 1-9: Switch to the corresponding tab + * - T: Toggle theme (dark/light) + * - ?: Show keyboard shortcut help + * - R: Refresh events + * - Escape: Close shortcut help overlay + * + * Shortcuts are disabled while the user is typing in an input, textarea, + * or contentEditable element. + */ +import { useEffect, useCallback } from 'react'; +import type { Tab } from '../components/MobileNavDrawer'; +import { NAV_ITEMS } from '../components/MobileNavDrawer'; + +interface UseKeyboardShortcutsOptions { + activeTab: Tab; + onTabChange: (tab: Tab) => void; + onToggleTheme: () => void; + onRefresh?: () => void; + helpOpen: boolean; + onToggleHelp: () => void; + onCloseHelp: () => void; +} + +function isEditableElement(el: Element | null): boolean { + if (!el) return false; + const tag = el.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; + if ((el as HTMLElement).isContentEditable) return true; + return false; +} + +export function useKeyboardShortcuts({ + activeTab, + onTabChange, + onToggleTheme, + onRefresh, + helpOpen, + onToggleHelp, + onCloseHelp, +}: UseKeyboardShortcutsOptions) { + const handleKeyDown = useCallback( + (e: globalThis.KeyboardEvent) => { + // Always allow Escape to close help + if (e.key === 'Escape' && helpOpen) { + onCloseHelp(); + return; + } + + // Skip shortcuts while typing in form fields + if (isEditableElement(e.target as Element)) return; + + // Skip if modifier keys are held (Ctrl, Alt, Meta) to avoid browser conflicts + if (e.ctrlKey || e.altKey || e.metaKey) return; + + // ? — Toggle help + if (e.key === '?' || (e.shiftKey && e.key === '/')) { + e.preventDefault(); + onToggleHelp(); + return; + } + + // Don't process further shortcuts if help is open + if (helpOpen) return; + + // 1-9 — Switch tabs + const num = parseInt(e.key, 10); + if (num >= 1 && num <= 9 && num <= NAV_ITEMS.length) { + e.preventDefault(); + const targetTab = NAV_ITEMS[num - 1]?.id; + if (targetTab) onTabChange(targetTab); + return; + } + + // T — Toggle theme + if (e.key === 't' || e.key === 'T') { + e.preventDefault(); + onToggleTheme(); + return; + } + + // R — Refresh + if ((e.key === 'r' || e.key === 'R') && onRefresh) { + e.preventDefault(); + onRefresh(); + return; + } + }, + [activeTab, onTabChange, onToggleTheme, onRefresh, helpOpen, onToggleHelp, onCloseHelp], + ); + + useEffect(() => { + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [handleKeyDown]); +} diff --git a/dashboard/src/index.css b/dashboard/src/index.css index 7e0a6412..7a6aaa7c 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -28,6 +28,29 @@ --hm-preset-active-color: #93c5fd; --hm-accent: #60a5fa; --hm-stat-glow: rgba(96, 165, 250, 0.08); + + /* ── Badge design tokens (dark) ── */ + --badge-padding-y: 3px; + --badge-padding-x: 10px; + --badge-radius: 999px; + --badge-font-size: 0.75rem; + --badge-font-weight: 600; + --badge-line-height: 1.4; + + --badge-green-bg: rgba(52, 211, 153, 0.15); + --badge-green-fg: #34d399; + --badge-blue-bg: rgba(96, 165, 250, 0.15); + --badge-blue-fg: #60a5fa; + --badge-red-bg: rgba(248, 113, 113, 0.15); + --badge-red-fg: #f87171; + --badge-yellow-bg: rgba(244, 180, 0, 0.15); + --badge-yellow-fg: #f4b400; + --badge-purple-bg: rgba(167, 139, 250, 0.15); + --badge-purple-fg: #a78bfa; + --badge-orange-bg: rgba(251, 146, 60, 0.15); + --badge-orange-fg: #fb923c; + --badge-neutral-bg: rgba(255, 255, 255, 0.08); + --badge-neutral-fg: #9aa0a6; } /* ── Light theme overrides ── */ @@ -57,6 +80,22 @@ --hm-preset-active-color: #7c3aed; --hm-accent: #7c3aed; --hm-stat-glow: rgba(124, 58, 237, 0.08); + + /* ── Badge design tokens (light) ── */ + --badge-green-bg: rgba(16, 125, 82, 0.1); + --badge-green-fg: #0d7d4c; + --badge-blue-bg: rgba(37, 99, 195, 0.1); + --badge-blue-fg: #2563eb; + --badge-red-bg: rgba(220, 53, 53, 0.1); + --badge-red-fg: #dc2626; + --badge-yellow-bg: rgba(180, 130, 0, 0.1); + --badge-yellow-fg: #b48200; + --badge-purple-bg: rgba(124, 58, 237, 0.1); + --badge-purple-fg: #7c3aed; + --badge-orange-bg: rgba(220, 110, 40, 0.1); + --badge-orange-fg: #dc6e28; + --badge-neutral-bg: rgba(0, 0, 0, 0.06); + --badge-neutral-fg: #6b7280; } /* Light theme overrides for existing components */ @@ -1238,10 +1277,11 @@ body { .template-preview__badge { display: inline-block; - padding: 4px 12px; + padding: var(--badge-padding-y) var(--badge-padding-x); border-radius: 12px; - font-size: 0.85rem; - font-weight: 500; + font-size: var(--badge-font-size); + font-weight: var(--badge-font-weight); + line-height: var(--badge-line-height); color: white; width: fit-content; } @@ -1554,10 +1594,11 @@ body { .preview-webhook__badge { background: #34A853; color: white; - padding: 4px 12px; + padding: var(--badge-padding-y) var(--badge-padding-x); border-radius: 6px; - font-weight: 600; - font-size: 0.85rem; + font-weight: var(--badge-font-weight); + font-size: var(--badge-font-size); + line-height: var(--badge-line-height); } .preview-webhook__url { @@ -1748,10 +1789,11 @@ body { } .template-card__badge { - padding: 4px 10px; + padding: var(--badge-padding-y) var(--badge-padding-x); border-radius: 6px; - font-size: 0.75rem; - font-weight: 600; + font-size: var(--badge-font-size); + font-weight: var(--badge-font-weight); + line-height: var(--badge-line-height); color: white; text-transform: uppercase; } @@ -2031,10 +2073,11 @@ body { .event-card__badge { display: inline-block; max-width: 100%; - padding: 2px 10px; - border-radius: 999px; - font-size: 0.75rem; - font-weight: 600; + padding: var(--badge-padding-y) var(--badge-padding-x); + border-radius: var(--badge-radius); + font-size: var(--badge-font-size); + font-weight: var(--badge-font-weight); + line-height: var(--badge-line-height); letter-spacing: 0.03em; white-space: nowrap; overflow: hidden; @@ -2061,38 +2104,38 @@ body { } .event-card__badge--green { - background: rgba(52, 211, 153, 0.15); - color: #34d399; + background: var(--badge-green-bg); + color: var(--badge-green-fg); } .event-card__badge--blue { - background: rgba(96, 165, 250, 0.15); - color: #60a5fa; + background: var(--badge-blue-bg); + color: var(--badge-blue-fg); } .event-card__badge--red { - background: rgba(248, 113, 113, 0.15); - color: #f87171; + background: var(--badge-red-bg); + color: var(--badge-red-fg); } .event-card__badge--yellow { - background: rgba(244, 180, 0, 0.15); - color: #f4b400; + background: var(--badge-yellow-bg); + color: var(--badge-yellow-fg); } .event-card__badge--purple { - background: rgba(167, 139, 250, 0.15); - color: #a78bfa; + background: var(--badge-purple-bg); + color: var(--badge-purple-fg); } .event-card__badge--orange { - background: rgba(251, 146, 60, 0.15); - color: #fb923c; + background: var(--badge-orange-bg); + color: var(--badge-orange-fg); } .event-card__badge--default { - background: rgba(255, 255, 255, 0.08); - color: #9aa0a6; + background: var(--badge-neutral-bg); + color: var(--badge-neutral-fg); } /* Loading / skeleton state */ @@ -2779,33 +2822,34 @@ body.event-explorer--resizing { display: inline-flex; align-items: center; justify-content: center; - padding: 5px 10px; - border-radius: 999px; - font-size: 0.75rem; - font-weight: 700; + padding: var(--badge-padding-y) var(--badge-padding-x); + border-radius: var(--badge-radius); + font-size: var(--badge-font-size); + font-weight: var(--badge-font-weight); + line-height: var(--badge-line-height); letter-spacing: 0.04em; text-transform: uppercase; white-space: nowrap; } .event-explorer__badge--blue { - color: #93c5fd; - background: rgba(96, 165, 250, 0.14); + color: var(--badge-blue-fg); + background: var(--badge-blue-bg); } .event-explorer__badge--purple { - color: #d8b4fe; - background: rgba(167, 139, 250, 0.14); + color: var(--badge-purple-fg); + background: var(--badge-purple-bg); } .event-explorer__badge--paused { - color: #f4b400; - background: rgba(244, 180, 0, 0.14); + color: var(--badge-yellow-fg); + background: var(--badge-yellow-bg); } .event-explorer__badge--default { - color: #e2e8f0; - background: rgba(255, 255, 255, 0.08); + color: var(--badge-neutral-fg); + background: var(--badge-neutral-bg); } .event-explorer__copy-button { @@ -3054,22 +3098,23 @@ body.event-explorer--resizing { } .contract-status-card__badge { - padding: 4px 10px; - border-radius: 999px; - font-size: 0.75rem; - font-weight: 700; + padding: var(--badge-padding-y) var(--badge-padding-x); + border-radius: var(--badge-radius); + font-size: var(--badge-font-size); + font-weight: var(--badge-font-weight); + line-height: var(--badge-line-height); letter-spacing: 0.05em; text-transform: uppercase; } .contract-status-card__badge--active { - color: #34d399; - background: rgba(52, 211, 153, 0.15); + color: var(--badge-green-fg); + background: var(--badge-green-bg); } .contract-status-card__badge--paused { - color: #f4b400; - background: rgba(244, 180, 0, 0.15); + color: var(--badge-yellow-fg); + background: var(--badge-yellow-bg); } .contract-status-card__error { @@ -3433,35 +3478,37 @@ body.event-explorer--resizing { } .format-badge { - padding: 2px 8px; - border-radius: 4px; - font-size: 0.75rem; - font-weight: 600; + padding: var(--badge-padding-y) var(--badge-padding-x); + border-radius: 6px; + font-size: var(--badge-font-size); + font-weight: var(--badge-font-weight); + line-height: var(--badge-line-height); } .format-badge--csv { - background: rgba(16, 185, 129, 0.1); - color: #10b981; + background: var(--badge-green-bg); + color: var(--badge-green-fg); } .format-badge--json { - background: rgba(245, 158, 11, 0.1); - color: #f59e0b; + background: var(--badge-yellow-bg); + color: var(--badge-yellow-fg); } .format-badge--pdf { - background: rgba(239, 68, 68, 0.1); - color: #ef4444; + background: var(--badge-red-bg); + color: var(--badge-red-fg); } .status-badge { display: inline-flex; align-items: center; gap: 6px; - padding: 4px 10px; + padding: var(--badge-padding-y) var(--badge-padding-x); border-radius: 12px; - font-size: 0.8rem; - font-weight: 500; + font-size: var(--badge-font-size); + font-weight: var(--badge-font-weight); + line-height: var(--badge-line-height); } .status-badge__dot { @@ -3471,32 +3518,32 @@ body.event-explorer--resizing { } .status-badge--completed { - background: rgba(16, 185, 129, 0.1); - color: #10b981; + background: var(--badge-green-bg); + color: var(--badge-green-fg); } .status-badge--completed .status-badge__dot { - background: #10b981; - box-shadow: 0 0 8px #10b981; + background: var(--badge-green-fg); + box-shadow: 0 0 8px var(--badge-green-fg); } .status-badge--processing { - background: rgba(59, 130, 246, 0.1); - color: #3b82f6; + background: var(--badge-blue-bg); + color: var(--badge-blue-fg); } .status-badge--processing .status-badge__dot { - background: #3b82f6; + background: var(--badge-blue-fg); animation: pulse-badge-animation 1.5s infinite alternate; } .status-badge--failed { - background: rgba(239, 68, 68, 0.1); - color: #ef4444; + background: var(--badge-red-bg); + color: var(--badge-red-fg); } .status-badge--failed .status-badge__dot { - background: #ef4444; + background: var(--badge-red-fg); } @keyframes pulse-badge-animation { @@ -3507,7 +3554,7 @@ body.event-explorer--resizing { 100% { transform: scale(1.2); opacity: 1; - box-shadow: 0 0 8px #3b82f6; + box-shadow: 0 0 8px var(--badge-blue-fg); } } @@ -4391,14 +4438,15 @@ body.event-explorer--resizing { .webhook-filters__mock-badge { display: inline-block; - padding: 3px 9px; - border-radius: 999px; - font-size: 0.72rem; - font-weight: 700; + padding: var(--badge-padding-y) var(--badge-padding-x); + border-radius: var(--badge-radius); + font-size: var(--badge-font-size); + font-weight: var(--badge-font-weight); + line-height: var(--badge-line-height); letter-spacing: 0.06em; text-transform: uppercase; - background: rgba(244, 180, 0, 0.16); - color: #f4b400; + background: var(--badge-yellow-bg); + color: var(--badge-yellow-fg); } .webhook-filters__refresh-btn { @@ -4592,12 +4640,13 @@ body.event-explorer--resizing { /* Event type badge */ .webhook-failed-table__event-badge { display: inline-block; - padding: 2px 9px; - border-radius: 999px; - font-size: 0.75rem; - font-weight: 600; - background: rgba(255, 255, 255, 0.08); - color: #e2e8f0; + padding: var(--badge-padding-y) var(--badge-padding-x); + border-radius: var(--badge-radius); + font-size: var(--badge-font-size); + font-weight: var(--badge-font-weight); + line-height: var(--badge-line-height); + background: var(--badge-neutral-bg); + color: var(--badge-neutral-fg); letter-spacing: 0.02em; text-transform: lowercase; } @@ -4605,45 +4654,46 @@ body.event-explorer--resizing { /* HTTP status code badges */ .webhook-failed-table__code { display: inline-block; - padding: 3px 10px; + padding: 3px var(--badge-padding-x); border-radius: 6px; - font-size: 0.8rem; - font-weight: 700; + font-size: var(--badge-font-size); + font-weight: var(--badge-font-weight); font-family: 'Courier New', Courier, monospace; - background: rgba(255, 255, 255, 0.07); - color: #e8eaed; + background: var(--badge-neutral-bg); + color: var(--badge-neutral-fg); } .webhook-failed-table__code--4xx { - background: rgba(251, 146, 60, 0.15); - color: #fb923c; + background: var(--badge-orange-bg); + color: var(--badge-orange-fg); } .webhook-failed-table__code--5xx { - background: rgba(248, 113, 113, 0.15); - color: #f87171; + background: var(--badge-red-bg); + color: var(--badge-red-fg); } .webhook-failed-table__code--network { - background: rgba(167, 139, 250, 0.15); - color: #c4b5fd; + background: var(--badge-purple-bg); + color: var(--badge-purple-fg); } /* Error category badges */ .webhook-failed-table__category { display: inline-block; - padding: 2px 8px; - border-radius: 999px; - font-size: 0.72rem; - font-weight: 700; + padding: var(--badge-padding-y) var(--badge-padding-x); + border-radius: var(--badge-radius); + font-size: var(--badge-font-size); + font-weight: var(--badge-font-weight); + line-height: var(--badge-line-height); letter-spacing: 0.05em; text-transform: uppercase; } -.webhook-failed-table__category--4xx { background: rgba(251, 146, 60, 0.14); color: #fb923c; } -.webhook-failed-table__category--5xx { background: rgba(248, 113, 113, 0.14); color: #f87171; } -.webhook-failed-table__category--timeout { background: rgba(244, 180, 0, 0.14); color: #f4b400; } -.webhook-failed-table__category--network { background: rgba(167, 139, 250, 0.14); color: #a78bfa; } +.webhook-failed-table__category--4xx { background: var(--badge-orange-bg); color: var(--badge-orange-fg); } +.webhook-failed-table__category--5xx { background: var(--badge-red-bg); color: var(--badge-red-fg); } +.webhook-failed-table__category--timeout { background: var(--badge-yellow-bg); color: var(--badge-yellow-fg); } +.webhook-failed-table__category--network { background: var(--badge-purple-bg); color: var(--badge-purple-fg); } /* Expand/collapse payload button */ .webhook-failed-table__expand-btn { @@ -5518,38 +5568,41 @@ body.event-explorer--resizing { } .notif-result-card__source { - font-size: 0.75rem; - padding: 2px 8px; - border-radius: 4px; - font-weight: 600; + font-size: var(--badge-font-size); + padding: var(--badge-padding-y) var(--badge-padding-x); + border-radius: 6px; + font-weight: var(--badge-font-weight); + line-height: var(--badge-line-height); text-transform: uppercase; letter-spacing: 0.04em; } -.notif-result-card__source--scheduled { background: rgba(74,158,255,0.15); color: #4a9eff; } -.notif-result-card__source--processed { background: rgba(52,211,153,0.15); color: #34d399; } +.notif-result-card__source--scheduled { background: var(--badge-blue-bg); color: var(--badge-blue-fg); } +.notif-result-card__source--processed { background: var(--badge-green-bg); color: var(--badge-green-fg); } .notif-result-card__status { - font-size: 0.75rem; - padding: 2px 8px; - border-radius: 4px; - font-weight: 600; + font-size: var(--badge-font-size); + padding: var(--badge-padding-y) var(--badge-padding-x); + border-radius: 6px; + font-weight: var(--badge-font-weight); + line-height: var(--badge-line-height); text-transform: uppercase; } .notif-result-card__status--completed, -.notif-result-card__status--processed { background: rgba(52,211,153,0.1); color: #34d399; } -.notif-result-card__status--pending { background: rgba(251,191,36,0.1); color: #fbbf24; } -.notif-result-card__status--failed { background: rgba(239,68,68,0.1); color: #ef4444; } -.notif-result-card__status--processing { background: rgba(74,158,255,0.1); color: #4a9eff; } -.notif-result-card__status--cancelled { background: rgba(156,163,175,0.1); color: #9ca3af; } +.notif-result-card__status--processed { background: var(--badge-green-bg); color: var(--badge-green-fg); } +.notif-result-card__status--pending { background: var(--badge-yellow-bg); color: var(--badge-yellow-fg); } +.notif-result-card__status--failed { background: var(--badge-red-bg); color: var(--badge-red-fg); } +.notif-result-card__status--processing { background: var(--badge-blue-bg); color: var(--badge-blue-fg); } +.notif-result-card__status--cancelled { background: var(--badge-neutral-bg); color: var(--badge-neutral-fg); } .notif-result-card__type { - font-size: 0.75rem; - color: #9aa0a6; - padding: 2px 8px; + font-size: var(--badge-font-size); + color: var(--badge-neutral-fg); + padding: var(--badge-padding-y) var(--badge-padding-x); border: 1px solid rgba(255,255,255,0.1); - border-radius: 4px; + border-radius: 6px; + line-height: var(--badge-line-height); } .notif-result-card__fields { @@ -5784,10 +5837,11 @@ body.event-explorer--resizing { display: inline-flex; align-items: center; gap: 6px; - padding: 4px 12px; - border-radius: 999px; - font-size: 0.78rem; - font-weight: 600; + padding: var(--badge-padding-y) var(--badge-padding-x); + border-radius: var(--badge-radius); + font-size: var(--badge-font-size); + font-weight: var(--badge-font-weight); + line-height: var(--badge-line-height); letter-spacing: 0.04em; } @@ -5801,18 +5855,18 @@ body.event-explorer--resizing { } .cd-channel-card__status--active { - background: rgba(52, 199, 89, 0.12); - color: #34c759; + background: var(--badge-green-bg); + color: var(--badge-green-fg); } .cd-channel-card__status--degraded { - background: rgba(244, 180, 0, 0.12); - color: #f4b400; + background: var(--badge-yellow-bg); + color: var(--badge-yellow-fg); } .cd-channel-card__status--inactive { - background: rgba(255, 255, 255, 0.06); - color: #9aa0a6; + background: var(--badge-neutral-bg); + color: var(--badge-neutral-fg); } /* Description */ @@ -7044,6 +7098,41 @@ textarea { background: rgba(26, 115, 232, 0.08); } +/* ── Tab shortcut hint (#505) ────────────────────────────────────────── */ + +.app-tabs__shortcut { + display: inline-block; + margin-right: 4px; + padding: 1px 5px; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 3px; + background: rgba(255, 255, 255, 0.05); + font-family: 'SF Mono', 'Consolas', 'Courier New', monospace; + font-size: 0.65rem; + font-weight: 600; + color: #6b7280; + line-height: 1.3; + vertical-align: middle; + opacity: 0.7; + transition: opacity 0.15s ease; +} + +.app-tabs__btn:hover .app-tabs__shortcut, +.app-tabs__btn:focus-visible .app-tabs__shortcut { + opacity: 1; +} + +[data-theme="light"] .app-tabs__shortcut { + border-color: rgba(0, 0, 0, 0.12); + background: rgba(0, 0, 0, 0.03); + color: #9ca3af; +} + +[data-theme="light"] .app-tabs__btn:hover .app-tabs__shortcut, +[data-theme="light"] .app-tabs__btn:focus-visible .app-tabs__shortcut { + opacity: 1; +} + [data-theme="light"] .toast--success { background: #e6f4ea; color: #137333; @@ -7067,3 +7156,158 @@ textarea { color: #a16f00; border-left-color: #fbbc04; } + +/* ═══════════════════════════════════════════════════════════════════════════ + #505 Keyboard Shortcuts Help Overlay + ═══════════════════════════════════════════════════════════════════════════ */ + +.shortcuts-overlay { + position: fixed; + inset: 0; + z-index: 9000; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.6); + backdrop-filter: blur(4px); +} + +.shortcuts-dialog { + position: relative; + width: 90vw; + max-width: 480px; + max-height: 80vh; + overflow-y: auto; + background: #1a1d27; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 12px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + color: #e8eaed; +} + +[data-theme="light"] .shortcuts-dialog { + background: #ffffff; + border-color: rgba(0, 0, 0, 0.12); + color: #1a1a2e; +} + +.shortcuts-dialog__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 20px; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); +} + +[data-theme="light"] .shortcuts-dialog__header { + border-bottom-color: rgba(0, 0, 0, 0.08); +} + +.shortcuts-dialog__title { + margin: 0; + font-size: 1.1rem; + font-weight: 600; +} + +.shortcuts-dialog__close { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: none; + border-radius: 6px; + background: rgba(255, 255, 255, 0.06); + color: #9aa0a6; + font-size: 1.2rem; + cursor: pointer; + transition: background 0.15s ease; +} + +.shortcuts-dialog__close:hover { + background: rgba(255, 255, 255, 0.12); +} + +[data-theme="light"] .shortcuts-dialog__close { + background: rgba(0, 0, 0, 0.04); + color: #6b7280; +} + +[data-theme="light"] .shortcuts-dialog__close:hover { + background: rgba(0, 0, 0, 0.08); +} + +.shortcuts-dialog__body { + padding: 16px 20px; +} + +.shortcuts-list { + margin: 0; + display: flex; + flex-direction: column; + gap: 8px; +} + +.shortcuts-list__item { + display: flex; + align-items: center; + gap: 12px; + padding: 6px 0; +} + +.shortcuts-list__key { + flex-shrink: 0; + min-width: 48px; + text-align: center; +} + +.shortcuts-list__key kbd, +.shortcuts-dialog__footer kbd { + display: inline-block; + padding: 3px 8px; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 4px; + background: rgba(255, 255, 255, 0.06); + font-family: 'SF Mono', 'Consolas', 'Courier New', monospace; + font-size: 0.78rem; + font-weight: 600; + color: #e8eaed; + line-height: 1.4; + white-space: nowrap; +} + +[data-theme="light"] .shortcuts-list__key kbd, +[data-theme="light"] .shortcuts-dialog__footer kbd { + border-color: rgba(0, 0, 0, 0.15); + background: rgba(0, 0, 0, 0.04); + color: #374151; +} + +.shortcuts-list__label { + margin: 0; + font-size: 0.88rem; + color: #b0b8c2; +} + +[data-theme="light"] .shortcuts-list__label { + color: #4b5563; +} + +.shortcuts-dialog__footer { + padding: 12px 20px; + border-top: 1px solid rgba(255, 255, 255, 0.08); +} + +[data-theme="light"] .shortcuts-dialog__footer { + border-top-color: rgba(0, 0, 0, 0.08); +} + +.shortcuts-dialog__hint { + font-size: 0.78rem; + color: #6b7280; +} + +[data-theme="light"] .shortcuts-dialog__hint { + color: #9ca3af; +} From 7b78e7f67863062cb93554a78263cb0d696517b0 Mon Sep 17 00:00:00 2001 From: Demilade10 Date: Fri, 28 Aug 2026 17:16:09 +0100 Subject: [PATCH 2/2] feat: add RPC endpoint benchmark tool (#776) Implement a diagnostic utility that benchmarks multiple Soroban RPC endpoints and helps users identify and connect to the fastest one. - Add benchmark service using direct JSON-RPC fetch calls in parallel - Add Zustand store for managing endpoints, results, and active RPC config - Add RPC Benchmark page with summary strip, results table, and endpoint management - Support adding custom private endpoints - Measure latency, block height, sync status, and availability - Rank endpoints by performance and provide 'Connect to Fastest' action - Persist selected RPC endpoint in localStorage - Add responsive CSS with dark/light theme support - Register new tab in navigation (Diagnostics group) --- dashboard/package-lock.json | 302 ++++----- dashboard/package.json | 3 +- dashboard/src/App.tsx | 3 + dashboard/src/components/MobileNavDrawer.tsx | 4 +- dashboard/src/config/rpcConfig.ts | 41 ++ dashboard/src/config/rpcEndpoints.ts | 41 ++ dashboard/src/index.css | 620 ++++++++++++++++++ dashboard/src/pages/RpcBenchmarkPage.tsx | 448 +++++++++++++ dashboard/src/services/rpcBenchmarkService.ts | 166 +++++ dashboard/src/store/rpcBenchmarkStore.ts | 63 ++ 10 files changed, 1518 insertions(+), 173 deletions(-) create mode 100644 dashboard/src/config/rpcConfig.ts create mode 100644 dashboard/src/config/rpcEndpoints.ts create mode 100644 dashboard/src/pages/RpcBenchmarkPage.tsx create mode 100644 dashboard/src/services/rpcBenchmarkService.ts create mode 100644 dashboard/src/store/rpcBenchmarkStore.ts diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json index 757bce3d..80c92d2c 100644 --- a/dashboard/package-lock.json +++ b/dashboard/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "dependencies": { "@creit.tech/stellar-wallets-kit": "^2.3.0", + "@stellar/stellar-sdk": "^13.3.0", "react": "^19.1.0", "react-dom": "^19.1.0", "zustand": "^5.0.6" @@ -36,8 +37,8 @@ "jest-axe": "^10.0.0", "jest-environment-jsdom": "^29.7.0", "jsdom": "^26.1.0", - "storybook": "^8.6.14", "prettier": "3.3.3", + "storybook": "^8.6.14", "ts-jest": "^29.2.5", "typescript": "^5.8.3", "vite": "^6.3.5" @@ -955,18 +956,6 @@ "node": ">=20" } }, - "node_modules/@creit.tech/stellar-wallets-kit/node_modules/eventsource": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-4.1.0.tgz", - "integrity": "sha512-2GuF51iuHX6A9xdTccMTsNb7VO0lHZihApxhvQzJB5A03DvHDd2FQepodbMaztPBmBcE/ox7o2gqaxGhYB9LhQ==", - "license": "MIT", - "dependencies": { - "eventsource-parser": "^3.0.1" - }, - "engines": { - "node": ">=20.0.0" - } - }, "node_modules/@creit.tech/stellar-wallets-kit/node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", @@ -980,21 +969,6 @@ "node": ">= 6" } }, - "node_modules/@creit.tech/stellar-wallets-kit/node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "license": "Apache-2.0", - "optional": true, - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=4.2.0" - } - }, "node_modules/@creit.tech/xbull-wallet-connect": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/@creit.tech/xbull-wallet-connect/-/xbull-wallet-connect-0.4.0.tgz", @@ -1667,20 +1641,20 @@ } }, "node_modules/@ethereumjs/common": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/@ethereumjs/common/-/common-10.1.2.tgz", - "integrity": "sha512-whWnhqAxwpDy4zWkM6rqMzb8nioZJpiys01N57+HDyanvK5IRzodV5tdMRDt66PD5vDjl2c9K5UcB039gU2Oyw==", + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/@ethereumjs/common/-/common-10.1.3.tgz", + "integrity": "sha512-ECWwbc4s8Ji7U0mkfZP6gIDy1FxXe0ZyVOTt0adkztPQRzX2DbH2ul7a83DSS8qYAiG6//vVXMkP60TyYOnv8Q==", "license": "MIT", "peer": true, "dependencies": { - "@ethereumjs/util": "^10.1.2", + "@ethereumjs/util": "^10.1.3", "eventemitter3": "^5.0.1" } }, "node_modules/@ethereumjs/rlp": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/@ethereumjs/rlp/-/rlp-10.1.2.tgz", - "integrity": "sha512-T5Zt6C2pd02Wd88Q9A5/UX+He1Q2Y1LntHxz/038tfbUMiqby4fYSSTLEDx+TEfJqw1BsJSBY/TSu6goUzlk+w==", + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/@ethereumjs/rlp/-/rlp-10.1.3.tgz", + "integrity": "sha512-d795YWkN6O8EJh/PgJ2zZKCdBMl8OlqkuVQESoidH666gaFtUuJ01jDtiuqfyOqTNcTdGhHi8LkQv1WEZzvZZw==", "license": "MPL-2.0", "peer": true, "bin": { @@ -1691,15 +1665,15 @@ } }, "node_modules/@ethereumjs/tx": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/@ethereumjs/tx/-/tx-10.1.2.tgz", - "integrity": "sha512-bAYK3YaYkk+auzxGfZSRVDbLHvboJNTx8/tV6jaqgPVlrA1QKEEADDEp/EGz+KI4NQmTGxEtXZ8tV/WjniRNww==", + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/@ethereumjs/tx/-/tx-10.1.3.tgz", + "integrity": "sha512-5uk9qB2MjyF4axL6GGPvYODe3uR/h6k36boDbvjH81WYFphw/JAIrI1thkWQX4BCyF8bi6suxORhi+vDc3lPlg==", "license": "MPL-2.0", "peer": true, "dependencies": { - "@ethereumjs/common": "^10.1.2", - "@ethereumjs/rlp": "^10.1.2", - "@ethereumjs/util": "^10.1.2", + "@ethereumjs/common": "^10.1.3", + "@ethereumjs/rlp": "^10.1.3", + "@ethereumjs/util": "^10.1.3", "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1" }, @@ -1708,13 +1682,13 @@ } }, "node_modules/@ethereumjs/tx/node_modules/@noble/curves": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", - "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz", + "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==", "license": "MIT", "peer": true, "dependencies": { - "@noble/hashes": "2.2.0" + "@noble/hashes": "2.4.0" }, "engines": { "node": ">= 20.19.0" @@ -1724,9 +1698,9 @@ } }, "node_modules/@ethereumjs/tx/node_modules/@noble/hashes": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", - "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", "license": "MIT", "peer": true, "engines": { @@ -1737,13 +1711,13 @@ } }, "node_modules/@ethereumjs/util": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/@ethereumjs/util/-/util-10.1.2.tgz", - "integrity": "sha512-UPBgXtHHfQugoXOSAoeG3jdmPbl37cwV9y3XqTPAnw8tJj8np14TPV2uc5lOs7C2LMF9Ubn66zyaiYxgwGppng==", + "version": "10.1.3", + "resolved": "https://registry.npmjs.org/@ethereumjs/util/-/util-10.1.3.tgz", + "integrity": "sha512-PhezOVe4DFaC+QLIsnK/tiOaXjpjS2F9AH4Ez0EfWevFw9NWNSg95l7I0dWuHF0B7v9odm2Ijqwo0EnARXtUmA==", "license": "MPL-2.0", "peer": true, "dependencies": { - "@ethereumjs/rlp": "^10.1.2", + "@ethereumjs/rlp": "^10.1.3", "@noble/curves": "^2.0.1", "@noble/hashes": "^2.0.1" }, @@ -1752,13 +1726,13 @@ } }, "node_modules/@ethereumjs/util/node_modules/@noble/curves": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", - "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz", + "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==", "license": "MIT", "peer": true, "dependencies": { - "@noble/hashes": "2.2.0" + "@noble/hashes": "2.4.0" }, "engines": { "node": ">= 20.19.0" @@ -1768,9 +1742,9 @@ } }, "node_modules/@ethereumjs/util/node_modules/@noble/hashes": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", - "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", "license": "MIT", "peer": true, "engines": { @@ -3303,9 +3277,9 @@ "peer": true }, "node_modules/@protobufjs/utf8": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz", - "integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", "license": "BSD-3-Clause", "peer": true }, @@ -3970,9 +3944,9 @@ } }, "node_modules/@sinclair/typebox": { - "version": "0.33.22", - "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.33.22.tgz", - "integrity": "sha512-auUj4k+f4pyrIVf4GW5UKquSZFHJWri06QgARy9C0t9ZTjJLIuNIrr1yl9bWcJWJ1Gz1vOvYN1D+QPaIlNMVkQ==", + "version": "0.33.24", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.33.24.tgz", + "integrity": "sha512-91up4t0BLjfkCZ4Ap/BcgaPrMaX6n0I2YRu+kc03xGhLy9G3N05nbTpnK8q7GXdv0PxwUoxi67A8o3Wr5U7j3w==", "license": "MIT", "peer": true }, @@ -5147,8 +5121,7 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@stellar/js-xdr/-/js-xdr-3.1.2.tgz", "integrity": "sha512-VVolPL5goVEIsvuGqDc5uiKxV03lzfWdvYg1KikvwheDmTBO68CKDji3bAZ/kppZrx5iTA8z3Ld5yuytcvhvOQ==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/@stellar/stellar-base": { "version": "13.1.0", @@ -5156,7 +5129,6 @@ "integrity": "sha512-90EArG+eCCEzDGj3OJNoCtwpWDwxjv+rs/RNPhvg4bulpjN/CSRj+Ys/SalRcfM4/WRC5/qAfjzmJBAuquWhkA==", "deprecated": "This package is now rolled into @stellar/stellar-sdk. Please use @stellar/stellar-sdk to continue receiving updates and support.", "license": "Apache-2.0", - "peer": true, "dependencies": { "@stellar/js-xdr": "^3.1.2", "base32.js": "^0.1.0", @@ -5177,7 +5149,6 @@ "resolved": "https://registry.npmjs.org/@stellar/stellar-sdk/-/stellar-sdk-13.3.0.tgz", "integrity": "sha512-8+GHcZLp+mdin8gSjcgfb/Lb6sSMYRX6Nf/0LcSJxvjLQR0XHpjGzOiRbYb2jSXo51EnA6kAV5j+4Pzh5OUKUg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@stellar/stellar-base": "^13.1.0", "axios": "^1.8.4", @@ -5197,7 +5168,6 @@ "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", "license": "MIT", - "peer": true, "dependencies": { "debug": "4" }, @@ -5206,11 +5176,10 @@ } }, "node_modules/@stellar/stellar-sdk/node_modules/axios": { - "version": "1.16.1", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", - "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.18.0.tgz", + "integrity": "sha512-E32NzpYKp++W7XRe52rHiXV2ehxmh3wbdgO7MHeFM+vqxLBYHzt0ElkiImtOBxtOmyp0yoC8C6uESVV84Y2/hw==", "license": "MIT", - "peer": true, "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.5", @@ -5218,12 +5187,20 @@ "proxy-from-env": "^2.1.0" } }, + "node_modules/@stellar/stellar-sdk/node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/@stellar/stellar-sdk/node_modules/https-proxy-agent": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", "license": "MIT", - "peer": true, "dependencies": { "agent-base": "6", "debug": "4" @@ -6355,6 +6332,16 @@ "tslib": "^2.6.2" } }, + "node_modules/@trezor/blockchain-link-utils/node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/@trezor/blockchain-link-utils/node_modules/protobufjs": { "version": "7.4.0", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.4.0.tgz", @@ -7190,6 +7177,16 @@ "node": ">=20.0.0" } }, + "node_modules/@trezor/blockchain-link/node_modules/eventsource": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", + "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/@trezor/connect": { "version": "9.7.3", "resolved": "https://registry.npmjs.org/@trezor/connect/-/connect-9.7.3.tgz", @@ -8165,13 +8162,13 @@ } }, "node_modules/@trezor/device-authenticity/node_modules/@noble/curves": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", - "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.4.0.tgz", + "integrity": "sha512-P4/62zrgfH33CneE3Dn4WhJVA22YUU0eR51wKIan4NVRvwsA0YnPTwWGpNbpuacSujmSFLvyzpyuR30+fbq2Ew==", "license": "MIT", "peer": true, "dependencies": { - "@noble/hashes": "2.2.0" + "@noble/hashes": "2.4.0" }, "engines": { "node": ">= 20.19.0" @@ -8181,9 +8178,9 @@ } }, "node_modules/@trezor/device-authenticity/node_modules/@noble/hashes": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", - "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", "license": "MIT", "peer": true, "engines": { @@ -9203,21 +9200,6 @@ "ws": "^7.5.1" } }, - "node_modules/@walletconnect/jsonrpc-ws-connection/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/@walletconnect/jsonrpc-ws-connection/node_modules/ws": { "version": "7.5.11", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", @@ -9812,9 +9794,9 @@ } }, "node_modules/@xrplf/isomorphic/node_modules/@noble/hashes": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", - "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.4.0.tgz", + "integrity": "sha512-X5XaVWZIBCT7HHZGm5I7ZQXDwLG+bGXuSrMQAW+7Zvl87h1kmc1ZB1VSRJcpUfoUrGQp4Fkoxm5kZ+Ms+aW+eA==", "license": "MIT", "peer": true, "engines": { @@ -10192,9 +10174,9 @@ } }, "node_modules/asn1.js/node_modules/bn.js": { - "version": "4.12.4", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.4.tgz", - "integrity": "sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g==", + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", "license": "MIT", "peer": true }, @@ -10462,12 +10444,11 @@ "license": "MIT" }, "node_modules/bare-addon-resolve": { - "version": "1.10.0", - "resolved": "https://registry.npmjs.org/bare-addon-resolve/-/bare-addon-resolve-1.10.0.tgz", - "integrity": "sha512-sSd0jieRJlDaODOzj0oe0RjFVC1QI0ZIjGIdPkbrTXsdVVtENg14c+lHHAhHwmWCZ2nQlMhy8jA3Y5LYPc/isA==", + "version": "1.10.1", + "resolved": "https://registry.npmjs.org/bare-addon-resolve/-/bare-addon-resolve-1.10.1.tgz", + "integrity": "sha512-F/SD2du8keuYSb4xipnGz5j2E6yhNdHA8ZVxtHae6h2uOrpBIjjbhXvjzKZbr5XUOzqBzh/i8GVFycj2DlFQIA==", "license": "Apache-2.0", "optional": true, - "peer": true, "dependencies": { "bare-module-resolve": "^1.10.0", "bare-semver": "^1.0.0" @@ -10482,12 +10463,11 @@ } }, "node_modules/bare-module-resolve": { - "version": "1.12.2", - "resolved": "https://registry.npmjs.org/bare-module-resolve/-/bare-module-resolve-1.12.2.tgz", - "integrity": "sha512-j+hiD5k99qec4KjJvYsI67q5AOBifmy9JG3oeMVxTmvrhn2sIdp8StrUvZu4YNgwTpO+NhniQG16N1ETDe1k5w==", + "version": "1.12.4", + "resolved": "https://registry.npmjs.org/bare-module-resolve/-/bare-module-resolve-1.12.4.tgz", + "integrity": "sha512-xcfgg2u7HqgJiBmah71O9vvdFAgHCvkqC/WSC2O7Bbgosoc1eC/BWe/6IDJ4OsfKlkxuvC/TDWXC+oH5yeW8mA==", "license": "Apache-2.0", "optional": true, - "peer": true, "dependencies": { "bare-semver": "^1.0.0" }, @@ -10505,8 +10485,7 @@ "resolved": "https://registry.npmjs.org/bare-semver/-/bare-semver-1.1.0.tgz", "integrity": "sha512-1Hw5qJ7hXdVt3uPUqjeFTuxyvBUJauvz5A1I2jk8gzjZMHp04n//6nV9MDbG9CMw78JHY2lGV0w6s//LrASm2w==", "license": "Apache-2.0", - "optional": true, - "peer": true + "optional": true }, "node_modules/base-x": { "version": "5.0.1", @@ -11308,9 +11287,9 @@ } }, "node_modules/create-ecdh/node_modules/bn.js": { - "version": "4.12.4", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.4.tgz", - "integrity": "sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g==", + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", "license": "MIT", "peer": true }, @@ -11815,9 +11794,9 @@ } }, "node_modules/diffie-hellman/node_modules/bn.js": { - "version": "4.12.4", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.4.tgz", - "integrity": "sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g==", + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", "license": "MIT", "peer": true }, @@ -12626,13 +12605,15 @@ } }, "node_modules/eventsource": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-2.0.2.tgz", - "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-4.1.1.tgz", + "integrity": "sha512-D6bTRWh6KahHTK/m4WnjPQyEinNPf9eFLEZSEoj7d6fTibspnAVYfzHvirL7u/aoX5d9YYfIkBVAhmigUELk9w==", "license": "MIT", - "peer": true, + "dependencies": { + "eventsource-parser": "^3.0.1" + }, "engines": { - "node": ">=12.0.0" + "node": ">=20.0.0" } }, "node_modules/eventsource-parser": { @@ -13753,9 +13734,9 @@ "license": "ISC" }, "node_modules/int64-buffer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/int64-buffer/-/int64-buffer-1.1.0.tgz", - "integrity": "sha512-94smTCQOvigN4d/2R/YDjz8YVG0Sufvv2aAh8P5m42gwhCsDAJqnbNOrxJsrADuAFAA69Q/ptGzxvNcNuIJcvw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/int64-buffer/-/int64-buffer-1.1.1.tgz", + "integrity": "sha512-xp/v0/im2q7n7ISFJXcYsr/aVsUdZKPUZS1uYtoM9I9Cfmm5YuAi7SQts2TrEXwAKIE0wyn3KxJbo35goJgvwQ==", "license": "MIT", "peer": true }, @@ -13775,9 +13756,9 @@ } }, "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", "license": "MIT", "peer": true, "engines": { @@ -14550,21 +14531,6 @@ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "license": "MIT" }, - "node_modules/jayson/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/jayson/node_modules/ws": { "version": "7.5.11", "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.11.tgz", @@ -16738,9 +16704,9 @@ } }, "node_modules/miller-rabin/node_modules/bn.js": { - "version": "4.12.4", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.4.tgz", - "integrity": "sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g==", + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", "license": "MIT", "peer": true }, @@ -17970,9 +17936,9 @@ } }, "node_modules/public-encrypt/node_modules/bn.js": { - "version": "4.12.4", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.4.tgz", - "integrity": "sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g==", + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", "license": "MIT", "peer": true }, @@ -18447,7 +18413,6 @@ "integrity": "sha512-VNPDZlYgIYQwWp9jMTzljx+k0ZtatKlcvOhktZ/anNPI3dQ9NXk7cq2U4iJ1wd9IrytRnYhyEocFWbkdPb+MYA==", "license": "Apache-2.0", "optional": true, - "peer": true, "dependencies": { "bare-addon-resolve": "^1.3.0" }, @@ -18601,9 +18566,9 @@ } }, "node_modules/ripple-address-codec/node_modules/@scure/base": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", - "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.3.0.tgz", + "integrity": "sha512-NsG6Y03tY6R5BUis4FdVtHVkur0U6FOzskgs9ZXNl78CUc9fkZ78HmENUle1nSOkCasDmbubmWD9qwB7mm4PZA==", "license": "MIT", "peer": true, "funding": { @@ -18611,9 +18576,9 @@ } }, "node_modules/ripple-binary-codec": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/ripple-binary-codec/-/ripple-binary-codec-2.8.0.tgz", - "integrity": "sha512-+NKnOi3hdzjm5dDpoZLUEaYon1jahPlSGnp3YrDoNMSR09ICEqgupN5wpEkPuqJvV75PF/g+W1QUwIXVzbEe7w==", + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/ripple-binary-codec/-/ripple-binary-codec-2.10.0.tgz", + "integrity": "sha512-H4pBm2WdOMmVnA0HGUdWAShmUFdmqavWAsi2WlMCzOknc1XjrZssa/RxEyziaPww8MXe1OpgGU7uCs8G2nYe/A==", "license": "ISC", "peer": true, "dependencies": { @@ -18986,7 +18951,6 @@ "resolved": "https://registry.npmjs.org/sha.js/-/sha.js-2.4.12.tgz", "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==", "license": "(MIT AND BSD-3-Clause)", - "peer": true, "dependencies": { "inherits": "^2.0.4", "safe-buffer": "^5.2.1", @@ -19203,7 +19167,6 @@ "integrity": "sha512-OnxSlN3uyY8D0EsLHpmm2HOFmKddQVvEMmsakCrXUzSd8kjjbzL413t4ZNF3n0UxSwNgwTyUvkmZHTfuCeiYSw==", "license": "MIT", "optional": true, - "peer": true, "dependencies": { "require-addon": "^1.1.0" } @@ -19723,9 +19686,9 @@ } }, "node_modules/tiny-secp256k1/node_modules/bn.js": { - "version": "4.12.4", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.4.tgz", - "integrity": "sha512-njR1b+ixG2ufvL9Zn9JGneW+b5GV6jqpYyPPpg4QVt723b5kJPGUczkUyWEH9BwEA74UakJZ43I4FDLBF7ci0g==", + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", "license": "MIT", "peer": true }, @@ -19829,7 +19792,6 @@ "resolved": "https://registry.npmjs.org/to-buffer/-/to-buffer-1.2.2.tgz", "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==", "license": "MIT", - "peer": true, "dependencies": { "isarray": "^2.0.5", "safe-buffer": "^5.2.1", @@ -19866,8 +19828,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/toml/-/toml-3.0.0.tgz", "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/tough-cookie": { "version": "5.1.2", @@ -20475,8 +20436,7 @@ "version": "1.19.11", "resolved": "https://registry.npmjs.org/urijs/-/urijs-1.19.11.tgz", "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/url-parse": { "version": "1.5.10", @@ -20506,9 +20466,9 @@ } }, "node_modules/usb/node_modules/node-addon-api": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.8.0.tgz", - "integrity": "sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==", + "version": "8.9.2", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.9.2.tgz", + "integrity": "sha512-VijLXbi3UACN69I0JVXJsX4tjACjNoQDgv2gTF6sx2wWEi8tkSg2eX8p5gSIFi8z2+DL3oHmY6OyKce38SDolg==", "license": "MIT", "peer": true, "engines": { diff --git a/dashboard/package.json b/dashboard/package.json index 73a43d18..dff18def 100644 --- a/dashboard/package.json +++ b/dashboard/package.json @@ -17,6 +17,7 @@ }, "dependencies": { "@creit.tech/stellar-wallets-kit": "^2.3.0", + "@stellar/stellar-sdk": "^13.3.0", "react": "^19.1.0", "react-dom": "^19.1.0", "zustand": "^5.0.6" @@ -44,8 +45,8 @@ "jest-axe": "^10.0.0", "jest-environment-jsdom": "^29.7.0", "jsdom": "^26.1.0", - "storybook": "^8.6.14", "prettier": "3.3.3", + "storybook": "^8.6.14", "ts-jest": "^29.2.5", "typescript": "^5.8.3", "vite": "^6.3.5" diff --git a/dashboard/src/App.tsx b/dashboard/src/App.tsx index c58f3386..867cdc4f 100644 --- a/dashboard/src/App.tsx +++ b/dashboard/src/App.tsx @@ -22,6 +22,7 @@ import { NotificationSearchPage } from './pages/NotificationSearchPage'; import { NotificationPreferencesPage } from './pages/NotificationPreferencesPage'; import { TemplatesPage } from './pages/TemplatesPage'; import { ChannelDetailsPage } from './pages/ChannelDetailsPage'; +import { RpcBenchmarkPage } from './pages/RpcBenchmarkPage'; import { ThemeToggle } from './components/ThemeToggle'; import { MobileNavDrawer, NAV_ITEMS, type Tab } from './components/MobileNavDrawer'; import { ToastProvider } from './context/ToastContext'; @@ -219,6 +220,8 @@ function renderPanel(tab: Tab, events: any[]) { return ; case 'channels': return ; + case 'rpc-benchmark': + return ; default: return null; } diff --git a/dashboard/src/components/MobileNavDrawer.tsx b/dashboard/src/components/MobileNavDrawer.tsx index 2f0467ca..3d0729de 100644 --- a/dashboard/src/components/MobileNavDrawer.tsx +++ b/dashboard/src/components/MobileNavDrawer.tsx @@ -21,7 +21,8 @@ export type Tab = | 'export-history' | 'search' | 'preferences' - | 'templates'; + | 'templates' + | 'rpc-benchmark'; export interface NavItem { id: Tab; @@ -40,6 +41,7 @@ export const NAV_ITEMS: NavItem[] = [ { id: 'search', label: 'Notification Search', group: 'Search & Config' }, { id: 'preferences', label: 'Preferences', group: 'Search & Config' }, { id: 'templates', label: 'Templates', group: 'Search & Config' }, + { id: 'rpc-benchmark', label: 'RPC Benchmark', group: 'Diagnostics' }, ]; const FOCUSABLE_SELECTOR = [ diff --git a/dashboard/src/config/rpcConfig.ts b/dashboard/src/config/rpcConfig.ts new file mode 100644 index 00000000..24b849cc --- /dev/null +++ b/dashboard/src/config/rpcConfig.ts @@ -0,0 +1,41 @@ +/** + * Manages the active Soroban RPC endpoint configuration. + * + * The selected endpoint is persisted in localStorage so it survives reloads. + * Falls back to the default SDF testnet endpoint when nothing is persisted. + */ + +const STORAGE_KEY = 'notify-chain:active-rpc-url'; +const DEFAULT_RPC_URL = 'https://soroban-testnet.stellar.org:443'; + +function readStorage(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +function writeStorage(key: string, value: string | null): void { + try { + if (value) { + localStorage.setItem(key, value); + } else { + localStorage.removeItem(key); + } + } catch { + // localStorage may be unavailable + } +} + +export function getActiveRpcUrl(): string { + return readStorage(STORAGE_KEY) ?? DEFAULT_RPC_URL; +} + +export function setActiveRpcUrl(url: string): void { + writeStorage(STORAGE_KEY, url); +} + +export function resetActiveRpcUrl(): void { + writeStorage(STORAGE_KEY, null); +} diff --git a/dashboard/src/config/rpcEndpoints.ts b/dashboard/src/config/rpcEndpoints.ts new file mode 100644 index 00000000..13c88201 --- /dev/null +++ b/dashboard/src/config/rpcEndpoints.ts @@ -0,0 +1,41 @@ +/** + * Predefined Soroban RPC endpoints for benchmarking. + * + * Users can also add custom private endpoints at runtime via the benchmark UI. + */ + +export interface RpcEndpoint { + id: string; + url: string; + label: string; + network: 'testnet' | 'mainnet' | 'custom'; +} + +export const DEFAULT_RPC_ENDPOINTS: RpcEndpoint[] = [ + { + id: 'sdf-testnet', + url: 'https://soroban-testnet.stellar.org:443', + label: 'SDF Testnet', + network: 'testnet', + }, + { + id: 'stellar-public-testnet', + url: 'https://rpc-stellar-testnet.ankr.com', + label: 'Ankr Testnet', + network: 'testnet', + }, + { + id: 'soroban-testnet-fast', + url: 'https://soroban-testnet早い.stellar.org:443', + label: 'SDF Testnet (Fast)', + network: 'testnet', + }, + { + id: 'custom-private', + url: 'http://localhost:8000', + label: 'Local Node', + network: 'custom', + }, +]; + +export const RPC_TIMEOUT_MS = 10_000; diff --git a/dashboard/src/index.css b/dashboard/src/index.css index 7a6aaa7c..2aca2090 100644 --- a/dashboard/src/index.css +++ b/dashboard/src/index.css @@ -7311,3 +7311,623 @@ textarea { [data-theme="light"] .shortcuts-dialog__hint { color: #9ca3af; } + +/* ── RPC Benchmark Page ───────────────────────────────────────────────────── */ + +.rpc-bench { + max-width: 1200px; + margin: 0 auto; + padding: 24px; + display: grid; + gap: 28px; +} + +.rpc-bench__header { + display: flex; + flex-wrap: wrap; + justify-content: space-between; + align-items: flex-start; + gap: 16px; +} + +.rpc-bench__header-actions { + display: flex; + gap: 12px; + flex-shrink: 0; +} + +.rpc-bench__eyebrow { + margin: 0 0 6px; + font-size: 0.82rem; + letter-spacing: 0.09em; + text-transform: uppercase; + color: #9aa0a6; +} + +.rpc-bench__title { + margin: 0 0 8px; + font-size: 1.75rem; + font-weight: 700; +} + +.rpc-bench__lead { + margin: 0; + color: #b0b8c2; + max-width: 56rem; +} + +/* Summary strip */ +.rpc-bench__summary { + display: flex; + flex-wrap: wrap; + gap: 0; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + background: rgba(255, 255, 255, 0.02); + overflow: hidden; +} + +.rpc-bench__summary-item { + flex: 1 1 140px; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 20px 16px; + border-right: 1px solid rgba(255, 255, 255, 0.06); +} + +.rpc-bench__summary-item:last-child { + border-right: none; +} + +.rpc-bench__summary-value { + font-size: 1.4rem; + font-weight: 700; + color: #e8eaed; + text-align: center; + word-break: break-all; +} + +.rpc-bench__summary-value--ok { + color: #34d399; +} + +.rpc-bench__summary-value--fail { + color: #f87171; +} + +.rpc-bench__summary-label { + font-size: 0.82rem; + color: #9aa0a6; + text-align: center; + letter-spacing: 0.03em; +} + +/* Section titles */ +.rpc-bench__section-title { + margin: 0 0 12px; + font-size: 1.1rem; + font-weight: 600; + color: #e8eaed; +} + +/* Endpoint chips */ +.rpc-bench__endpoint-list { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.rpc-bench__endpoint-chip { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 12px; + background: rgba(255, 255, 255, 0.04); + font-size: 0.88rem; +} + +.rpc-bench__endpoint-chip-label { + font-weight: 600; + color: #e8eaed; +} + +.rpc-bench__endpoint-chip-url { + color: #9aa0a6; + font-size: 0.8rem; + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.rpc-bench__endpoint-chip-network { + font-size: 0.72rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + padding: 2px 8px; + border-radius: 999px; +} + +.rpc-bench__endpoint-chip-network--testnet { + background: var(--badge-blue-bg); + color: var(--badge-blue-fg); +} + +.rpc-bench__endpoint-chip-network--mainnet { + background: var(--badge-green-bg); + color: var(--badge-green-fg); +} + +.rpc-bench__endpoint-chip-network--custom { + background: var(--badge-neutral-bg); + color: var(--badge-neutral-fg); +} + +.rpc-bench__endpoint-chip-active { + font-size: 0.72rem; + font-weight: 600; + padding: 2px 8px; + border-radius: 999px; + background: var(--badge-green-bg); + color: var(--badge-green-fg); +} + +.rpc-bench__endpoint-chip-remove { + background: transparent; + border: none; + color: #9aa0a6; + cursor: pointer; + font-size: 1.1rem; + line-height: 1; + padding: 0 2px; + transition: color 0.15s; +} + +.rpc-bench__endpoint-chip-remove:hover { + color: #f87171; +} + +/* Add form */ +.rpc-bench__add-form { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 16px; + padding: 18px; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + background: rgba(255, 255, 255, 0.025); +} + +.rpc-bench__form-row { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1 1 200px; +} + +.rpc-bench__form-label { + font-size: 0.82rem; + color: #9aa0a6; + letter-spacing: 0.03em; +} + +.rpc-bench__form-input { + padding: 10px 14px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + background: #12151c; + color: #e8eaed; + font-size: 0.92rem; + transition: border-color 0.15s; +} + +.rpc-bench__form-input:focus { + outline: none; + border-color: #5b7dff; +} + +.rpc-bench__form-input::placeholder { + color: #6b7280; +} + +.rpc-bench__form-error { + margin: 0; + font-size: 0.82rem; + color: #f87171; +} + +/* Buttons */ +.rpc-bench__btn { + padding: 10px 20px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 10px; + font-size: 0.92rem; + font-weight: 600; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; +} + +.rpc-bench__btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.rpc-bench__btn--primary { + background: #5b7dff; + border-color: #5b7dff; + color: #fff; +} + +.rpc-bench__btn--primary:hover:not(:disabled) { + background: #4a6aff; +} + +.rpc-bench__btn--connect { + background: rgba(52, 211, 153, 0.15); + border-color: rgba(52, 211, 153, 0.3); + color: #34d399; +} + +.rpc-bench__btn--connect:hover:not(:disabled) { + background: rgba(52, 211, 153, 0.25); +} + +.rpc-bench__btn--add { + background: rgba(96, 165, 250, 0.15); + border-color: rgba(96, 165, 250, 0.3); + color: #60a5fa; + flex-shrink: 0; +} + +.rpc-bench__btn--add:hover { + background: rgba(96, 165, 250, 0.25); +} + +/* Connected banner */ +.rpc-bench__connected { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 18px; + border-radius: 12px; + background: rgba(52, 211, 153, 0.1); + border: 1px solid rgba(52, 211, 153, 0.25); + color: #34d399; + font-size: 0.95rem; +} + +.rpc-bench__connected code { + background: rgba(52, 211, 153, 0.15); + padding: 2px 8px; + border-radius: 6px; + font-size: 0.85rem; +} + +/* Error banner */ +.rpc-bench__error-banner { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 18px; + border-radius: 12px; + background: rgba(244, 67, 54, 0.1); + border: 1px solid rgba(244, 67, 54, 0.25); + color: #ff6b6b; + font-size: 0.95rem; +} + +.rpc-bench__error-banner__retry { + margin-left: auto; + background: transparent; + border: 1px solid rgba(255, 107, 107, 0.4); + border-radius: 8px; + color: #ff6b6b; + cursor: pointer; + padding: 6px 14px; + font-size: 0.88rem; + transition: background 0.15s; +} + +.rpc-bench__error-banner__retry:hover { + background: rgba(255, 107, 107, 0.1); +} + +/* Results table */ +.rpc-bench__table-wrap { + overflow-x: auto; + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + background: rgba(255, 255, 255, 0.025); +} + +.rpc-bench__table { + width: 100%; + border-collapse: collapse; + font-size: 0.92rem; +} + +.rpc-bench__th { + text-align: left; + padding: 14px 16px; + font-size: 0.78rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #9aa0a6; + border-bottom: 1px solid rgba(255, 255, 255, 0.08); + white-space: nowrap; +} + +.rpc-bench__row { + border-bottom: 1px solid rgba(255, 255, 255, 0.04); + transition: background 0.15s; +} + +.rpc-bench__row:hover { + background: rgba(255, 255, 255, 0.03); +} + +.rpc-bench__row:last-child { + border-bottom: none; +} + +.rpc-bench__row--ok:first-child { + background: rgba(52, 211, 153, 0.05); +} + +.rpc-bench__cell { + padding: 14px 16px; + vertical-align: middle; + color: #e8eaed; +} + +.rpc-bench__cell--rank { + width: 60px; + text-align: center; +} + +.rpc-bench__rank { + font-weight: 700; + font-size: 0.95rem; +} + +.rpc-bench__rank--na { + color: #6b7280; +} + +.rpc-bench__cell--name { + display: flex; + flex-direction: column; + gap: 2px; +} + +.rpc-bench__endpoint-label { + font-weight: 600; +} + +.rpc-bench__endpoint-url { + font-size: 0.78rem; + color: #6b7280; + word-break: break-all; +} + +.rpc-bench__cell--latency { + font-size: 0.95rem; +} + +.rpc-bench__cell--block { + font-variant-numeric: tabular-nums; +} + +.rpc-bench__na { + color: #6b7280; +} + +.rpc-bench__sync { + font-size: 0.82rem; + font-weight: 600; +} + +.rpc-bench__sync--synced { + color: #34d399; +} + +.rpc-bench__sync--behind { + color: #f4b400; +} + +.rpc-bench__cell--status { + display: flex; + flex-direction: column; + gap: 4px; +} + +.rpc-bench__status { + display: inline-flex; + align-items: center; + gap: 6px; + padding: var(--badge-padding-y) var(--badge-padding-x); + border-radius: var(--badge-radius); + font-size: var(--badge-font-size); + font-weight: var(--badge-font-weight); + line-height: var(--badge-line-height); + letter-spacing: 0.04em; + width: fit-content; +} + +.rpc-bench__status::before { + content: ''; + display: block; + width: 7px; + height: 7px; + border-radius: 50%; + background: currentColor; +} + +.rpc-bench__status--ok { + background: var(--badge-green-bg); + color: var(--badge-green-fg); +} + +.rpc-bench__status--error { + background: var(--badge-red-bg); + color: var(--badge-red-fg); +} + +.rpc-bench__status--timeout { + background: var(--badge-yellow-bg); + color: var(--badge-yellow-fg); +} + +.rpc-bench__error-msg { + font-size: 0.72rem; + color: #f87171; + word-break: break-all; +} + +/* Empty state */ +.rpc-bench__empty { + padding: 32px; + text-align: center; + color: #9aa0a6; + border: 1px dashed rgba(255, 255, 255, 0.1); + border-radius: 14px; +} + +/* Skeleton */ +.rpc-bench__row--skeleton { + pointer-events: none; +} + +.rpc-bench__skeleton-line { + border-radius: 8px; + background: rgba(255, 255, 255, 0.05); + position: relative; + overflow: hidden; +} + +.rpc-bench__skeleton-line::before { + content: ''; + position: absolute; + inset: 0; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.07), transparent); + animation: shimmer 1.4s infinite; +} + +.rpc-bench__skeleton-line--sm { + height: 16px; + width: 60px; +} + +.rpc-bench__skeleton-line--md { + height: 16px; + width: 140px; +} + +/* Responsive */ +@media (max-width: 900px) { + .rpc-bench__summary-item { + flex: 1 1 100px; + padding: 14px 12px; + } + + .rpc-bench__summary-value { + font-size: 1.2rem; + } + + .rpc-bench__table { + font-size: 0.85rem; + } + + .rpc-bench__th, + .rpc-bench__cell { + padding: 10px 12px; + } +} + +@media (max-width: 768px) { + .rpc-bench { + padding: 16px; + } + + .rpc-bench__header { + flex-direction: column; + align-items: stretch; + } + + .rpc-bench__header-actions { + flex-direction: column; + } + + .rpc-bench__title { + font-size: 1.4rem; + } + + .rpc-bench__add-form { + flex-direction: column; + align-items: stretch; + } + + .rpc-bench__form-row { + flex: 1 1 auto; + } + + .rpc-bench__endpoint-chip { + flex-wrap: wrap; + } + + .rpc-bench__endpoint-chip-url { + max-width: 140px; + } + + .rpc-bench__cell--sync { + display: none; + } + + .rpc-bench__th:nth-child(4) { + display: none; + } +} + +/* Light theme overrides */ +[data-theme="light"] .rpc-bench__form-input { + background: #ffffff; + border-color: rgba(0, 0, 0, 0.15); + color: #1a1a2e; +} + +[data-theme="light"] .rpc-bench__form-input::placeholder { + color: #9ca3af; +} + +[data-theme="light"] .rpc-bench__endpoint-chip { + border-color: rgba(0, 0, 0, 0.1); + background: rgba(0, 0, 0, 0.03); +} + +[data-theme="light"] .rpc-bench__endpoint-chip-label { + color: #1a1a2e; +} + +[data-theme="light"] .rpc-bench__endpoint-chip-url { + color: #4b5563; +} + +[data-theme="light"] .rpc-bench__endpoint-chip-remove { + color: #4b5563; +} + +[data-theme="light"] .rpc-bench__endpoint-chip-remove:hover { + color: #dc2626; +} + +[data-theme="light"] .rpc-bench__empty { + border-color: rgba(0, 0, 0, 0.12); + color: #4b5563; +} diff --git a/dashboard/src/pages/RpcBenchmarkPage.tsx b/dashboard/src/pages/RpcBenchmarkPage.tsx new file mode 100644 index 00000000..a5118f10 --- /dev/null +++ b/dashboard/src/pages/RpcBenchmarkPage.tsx @@ -0,0 +1,448 @@ +import { useState, useCallback } from 'react'; +import { useRpcBenchmarkStore } from '../store/rpcBenchmarkStore'; +import type { BenchmarkResult } from '../services/rpcBenchmarkService'; +import type { RpcEndpoint } from '../config/rpcEndpoints'; + +// ── Helpers ────────────────────────────────────────────────────────────────── + +function latencyColor(ms: number | null): string { + if (ms === null) return '#9aa0a6'; + if (ms < 300) return '#34d399'; + if (ms < 800) return '#f4b400'; + return '#f87171'; +} + +function statusBadgeClass(status: BenchmarkResult['status']): string { + return `rpc-bench__status rpc-bench__status--${status}`; +} + +// ── Sub-components ─────────────────────────────────────────────────────────── + +function SummaryStrip({ + results, + activeRpcUrl, +}: { + results: BenchmarkResult[]; + activeRpcUrl: string; +}) { + const ok = results.filter((r) => r.status === 'ok').length; + const failed = results.filter((r) => r.status !== 'ok').length; + const fastest = results.find((r) => r.status === 'ok'); + const maxBlock = results + .filter((r) => r.blockHeight !== null) + .reduce((m, r) => Math.max(m, r.blockHeight!), 0); + + return ( +
+
+ {results.length} + Tested +
+
+ + {ok} + + Available +
+
+ + {failed} + + Failed +
+
+ + {fastest ? `${fastest.latencyMs}ms` : '—'} + + Fastest Latency +
+
+ + {maxBlock > 0 ? maxBlock.toLocaleString() : '—'} + + Highest Block +
+
+ + {activeRpcUrl.length > 30 + ? activeRpcUrl.slice(0, 30) + '...' + : activeRpcUrl} + + Active Endpoint +
+
+ ); +} + +function ResultRow({ result, maxBlock }: { result: BenchmarkResult; maxBlock: number }) { + const blockDiff = + result.blockHeight !== null && maxBlock > 0 + ? maxBlock - result.blockHeight + : null; + + return ( + + + {result.status === 'ok' && result.rank !== Infinity ? ( + #{result.rank} + ) : ( + + )} + + + {result.label} + {result.url} + + + {result.latencyMs !== null ? ( + + {result.latencyMs} ms + + ) : ( + + )} + + + {result.blockHeight !== null ? ( + result.blockHeight.toLocaleString() + ) : ( + + )} + + + {blockDiff !== null ? ( + + {blockDiff === 0 ? 'Synced' : `−${blockDiff}`} + + ) : ( + + )} + + + + {result.status === 'ok' + ? 'Available' + : result.status === 'timeout' + ? 'Timeout' + : 'Error'} + + {result.errorMessage && ( + + {result.errorMessage.length > 40 + ? result.errorMessage.slice(0, 40) + '...' + : result.errorMessage} + + )} + + + ); +} + +function ResultTable({ results }: { results: BenchmarkResult[] }) { + const maxBlock = results + .filter((r) => r.blockHeight !== null) + .reduce((m, r) => Math.max(m, r.blockHeight!), 0); + + return ( +
+ + + + + + + + + + + + + {results.map((r) => ( + + ))} + +
RankEndpointLatencyBlock HeightSyncStatus
+
+ ); +} + +function AddEndpointForm({ + onAdd, +}: { + onAdd: (ep: RpcEndpoint) => void; +}) { + const [label, setLabel] = useState(''); + const [url, setUrl] = useState(''); + const [error, setError] = useState(''); + + const handleSubmit = useCallback( + (e: React.FormEvent) => { + e.preventDefault(); + setError(''); + + const trimmedLabel = label.trim(); + const trimmedUrl = url.trim(); + + if (!trimmedLabel || !trimmedUrl) { + setError('Both label and URL are required.'); + return; + } + + try { + new URL(trimmedUrl); + } catch { + setError('Invalid URL format.'); + return; + } + + const id = `custom-${Date.now()}`; + onAdd({ + id, + url: trimmedUrl, + label: trimmedLabel, + network: 'custom', + }); + + setLabel(''); + setUrl(''); + }, + [label, url, onAdd], + ); + + return ( +
+
+ + setLabel(e.target.value)} + placeholder="My Private Node" + /> +
+
+ + setUrl(e.target.value)} + placeholder="https://my-rpc.example.com:8000" + /> +
+ {error && ( +

+ {error} +

+ )} + +
+ ); +} + +function SkeletonTable() { + return ( +
+ + + + + + + + + + + + + {[1, 2, 3, 4].map((i) => ( + + + + + + + + + ))} + +
RankEndpointLatencyBlock HeightSyncStatus
+
+
+
+
+
+
+
+
+
+
+
+
+
+ ); +} + +// ── Page ────────────────────────────────────────────────────────────────────── + +export function RpcBenchmarkPage() { + const { + endpoints, + results, + isRunning, + activeRpcUrl, + error, + addEndpoint, + removeEndpoint, + runTests, + connectToFastest, + } = useRpcBenchmarkStore(); + + const [connectedUrl, setConnectedUrl] = useState(null); + + const handleConnect = useCallback(() => { + const url = connectToFastest(); + if (url) { + setConnectedUrl(url); + } + }, [connectToFastest]); + + const handleRemove = useCallback( + (id: string) => { + removeEndpoint(id); + }, + [removeEndpoint], + ); + + return ( +
+ {/* Page header */} +
+
+

Diagnostics

+

RPC Endpoint Benchmark

+

+ Test multiple Soroban RPC endpoints in parallel to find the fastest + available node for your network. +

+
+
+ + {results.length > 0 && + results.some((r) => r.status === 'ok') && ( + + )} +
+
+ + {/* Connected confirmation */} + {connectedUrl && ( +
+ Connected! Active RPC set to{' '} + {connectedUrl} +
+ )} + + {/* Error banner */} + {error && ( +
+ Error: {error} + +
+ )} + + {/* Summary strip */} + {results.length > 0 && !isRunning && ( + + )} + + {/* Endpoint list with remove buttons */} +
+

Endpoints ({endpoints.length})

+
+ {endpoints.map((ep) => ( +
+ {ep.label} + {ep.url} + + {ep.network} + + {ep.url === activeRpcUrl && ( + Active + )} + +
+ ))} +
+
+ + {/* Add custom endpoint */} +
+

Add Custom Endpoint

+ +
+ + {/* Results */} +
+

Results

+ {isRunning ? ( + + ) : results.length > 0 ? ( + + ) : ( +

+ No results yet. Click Run Benchmark to test your + configured endpoints. +

+ )} +
+
+ ); +} diff --git a/dashboard/src/services/rpcBenchmarkService.ts b/dashboard/src/services/rpcBenchmarkService.ts new file mode 100644 index 00000000..c9b95042 --- /dev/null +++ b/dashboard/src/services/rpcBenchmarkService.ts @@ -0,0 +1,166 @@ +/** + * RPC Benchmark Service + * + * Benchmarks Soroban RPC endpoints by calling the JSON-RPC API directly via + * fetch. Measures response latency, retrieves ledger info, and reports + * availability — all in parallel. + */ + +import type { RpcEndpoint } from '../config/rpcEndpoints'; +import { RPC_TIMEOUT_MS } from '../config/rpcEndpoints'; + +export interface BenchmarkResult { + endpointId: string; + url: string; + label: string; + network: string; + latencyMs: number | null; + blockHeight: number | null; + status: 'ok' | 'error' | 'timeout'; + errorMessage?: string; + rank: number; +} + +interface JsonRpcResponse { + jsonrpc: string; + id: number; + result?: Record; + error?: { code: number; message: string }; +} + +async function rpcCall( + url: string, + method: string, + timeoutMs: number, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + + try { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + id: 1, + method, + params: {}, + }), + signal: controller.signal, + }); + + if (!res.ok) { + throw new Error(`HTTP ${res.status}`); + } + + const data = (await res.json()) as JsonRpcResponse; + + if (data.error) { + throw new Error(`RPC error ${data.error.code}: ${data.error.message}`); + } + + return data; + } finally { + clearTimeout(timer); + } +} + +async function benchmarkEndpoint( + endpoint: RpcEndpoint, + timeoutMs: number, +): Promise { + const base: Omit = { + endpointId: endpoint.id, + url: endpoint.url, + label: endpoint.label, + network: endpoint.network, + }; + + try { + const start = performance.now(); + + const [healthRes, ledgerRes] = await Promise.all([ + rpcCall(endpoint.url, 'getHealth', timeoutMs), + rpcCall(endpoint.url, 'getLatestLedger', timeoutMs), + ]); + + const latencyMs = Math.round(performance.now() - start); + + const healthStatus = healthRes.result?.status; + if (healthStatus !== 'alive') { + return { + ...base, + latencyMs, + blockHeight: null, + status: 'error', + errorMessage: `Unhealthy: ${String(healthStatus)}`, + rank: 0, + }; + } + + const blockHeight = + typeof ledgerRes.result?.sequence === 'number' + ? ledgerRes.result.sequence + : null; + + return { + ...base, + latencyMs, + blockHeight, + status: 'ok', + rank: 0, + }; + } catch (err: unknown) { + const isAbort = + err instanceof DOMException && err.name === 'AbortError'; + const message = + err instanceof Error ? err.message : 'Unknown error'; + + return { + ...base, + latencyMs: null, + blockHeight: null, + status: isAbort ? 'timeout' : 'error', + errorMessage: isAbort ? 'Request timed out' : message, + rank: 0, + }; + } +} + +function rankResults(results: BenchmarkResult[]): BenchmarkResult[] { + const successful = results + .filter((r) => r.status === 'ok' && r.latencyMs !== null) + .sort((a, b) => a.latencyMs! - b.latencyMs!); + + const failed = results.filter((r) => r.status !== 'ok'); + + const ranked: BenchmarkResult[] = []; + let currentRank = 1; + for (let i = 0; i < successful.length; i++) { + if ( + i > 0 && + successful[i].latencyMs === successful[i - 1].latencyMs + ) { + ranked.push({ ...successful[i], rank: ranked[ranked.length - 1].rank }); + } else { + ranked.push({ ...successful[i], rank: currentRank }); + currentRank = i + 2; + } + } + + return [...ranked, ...failed.map((r) => ({ ...r, rank: Infinity }))]; +} + +/** + * Benchmark all provided endpoints concurrently. + * Returns results sorted by rank (fastest first). + */ +export async function runBenchmark( + endpoints: RpcEndpoint[], + timeoutMs: number = RPC_TIMEOUT_MS, +): Promise { + const raw = await Promise.all( + endpoints.map((ep) => benchmarkEndpoint(ep, timeoutMs)), + ); + return rankResults(raw); +} diff --git a/dashboard/src/store/rpcBenchmarkStore.ts b/dashboard/src/store/rpcBenchmarkStore.ts new file mode 100644 index 00000000..21331af0 --- /dev/null +++ b/dashboard/src/store/rpcBenchmarkStore.ts @@ -0,0 +1,63 @@ +import { create } from 'zustand'; +import type { RpcEndpoint } from '../config/rpcEndpoints'; +import { DEFAULT_RPC_ENDPOINTS } from '../config/rpcEndpoints'; +import type { BenchmarkResult } from '../services/rpcBenchmarkService'; +import { runBenchmark } from '../services/rpcBenchmarkService'; +import { + getActiveRpcUrl, + setActiveRpcUrl, +} from '../config/rpcConfig'; + +export interface RpcBenchmarkState { + endpoints: RpcEndpoint[]; + results: BenchmarkResult[]; + isRunning: boolean; + activeRpcUrl: string; + error: string | null; + + addEndpoint: (endpoint: RpcEndpoint) => void; + removeEndpoint: (id: string) => void; + runTests: () => Promise; + connectToFastest: () => string | null; +} + +export const useRpcBenchmarkStore = create((set, get) => ({ + endpoints: [...DEFAULT_RPC_ENDPOINTS], + results: [], + isRunning: false, + activeRpcUrl: getActiveRpcUrl(), + error: null, + + addEndpoint: (endpoint) => + set((state) => ({ + endpoints: [...state.endpoints, endpoint], + })), + + removeEndpoint: (id) => + set((state) => ({ + endpoints: state.endpoints.filter((ep) => ep.id !== id), + })), + + runTests: async () => { + const { endpoints } = get(); + set({ isRunning: true, error: null, results: [] }); + + try { + const results = await runBenchmark(endpoints); + set({ results, isRunning: false }); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : 'Benchmark failed'; + set({ isRunning: false, error: message }); + } + }, + + connectToFastest: () => { + const { results } = get(); + const fastest = results.find((r) => r.status === 'ok'); + if (!fastest) return null; + + setActiveRpcUrl(fastest.url); + set({ activeRpcUrl: fastest.url }); + return fastest.url; + }, +}));