diff --git a/apps/web/app/alerts/[id]/page.tsx b/apps/web/app/alerts/[id]/page.tsx new file mode 100644 index 0000000..a7ca800 --- /dev/null +++ b/apps/web/app/alerts/[id]/page.tsx @@ -0,0 +1,93 @@ +import { Alert, MOCK_ALERTS } from '../types'; +import '../alerts.css'; + +interface AlertDetailsProps { + alert?: Alert; +} + +export function AlertDetails({ alert }: AlertDetailsProps) { + if (!alert) { + return ( +
+

Alert not found

+ + Back to alert feed + +
+ ); + } + + return ( +
+
+
+ + Back to alert feed + +

{alert.title}

+

+ {alert.id} · {alert.source} · {alert.network} +

+
+ {alert.severity} +
+ +
+
+

Detection explanation

+

{alert.explanation}

+
+
+

Risk score

+
+ {alert.riskScore} + / 100 +
+
+
+

Alert metadata

+
+
+
Detected
+
+ {new Date(alert.detectedAt).toLocaleString('en-US', { + dateStyle: 'medium', + timeStyle: 'short', + })} +
+
+ {alert.metadata.map(item => ( +
+
{item.label}
+
{item.value}
+
+ ))} +
+
+
+

Related events

+
    + {alert.events.map(event => ( +
  1. + + {event.type} · {event.status} + +

    {event.description}

    + +
  2. + ))} +
+
+
+
+ ); +} + +export default function AlertDetailsPage({ params }: { params: { id: string } }) { + return alert.id === params.id)} />; +} diff --git a/apps/web/app/alerts/alerts.css b/apps/web/app/alerts/alerts.css new file mode 100644 index 0000000..f786ffe --- /dev/null +++ b/apps/web/app/alerts/alerts.css @@ -0,0 +1,78 @@ +.alerts-page { + min-height: 100%; + padding: 1.5rem; + color: #e6edf3; + background: #0d1117; +} + +.alerts-header, +.alert-detail-header { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 1rem; + border-bottom: 1px solid #21262d; + padding-bottom: 1rem; + margin-bottom: 1.25rem; +} + +.alerts-title, +.alert-detail-title { margin: 0; font-size: 1.5rem; font-weight: 600; } +.alerts-subtitle, +.alert-detail-subtitle { margin: 0.25rem 0 0; color: #8b949e; font-size: 0.875rem; } +.alerts-summary { display: flex; gap: 1.25rem; margin: 0; } +.alerts-summary div { text-align: right; } +.alerts-summary dt { color: #8b949e; font-size: 0.7rem; text-transform: uppercase; } +.alerts-summary dd { margin: 0; font-size: 1.35rem; font-weight: 600; } +.alerts-summary .critical { color: #f85149; } + +.alert-list { display: grid; gap: 0.75rem; list-style: none; margin: 0; padding: 0; } +.alert-row { display: flex; gap: 1rem; padding: 1rem; border: 1px solid #21262d; border-radius: 8px; background: #161b22; } +.alert-row:hover { border-color: #58a6ff; } +.alert-row-main { min-width: 0; flex: 1; } +.alert-row-heading { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; } +.alert-row-title { margin: 0; font-size: 1rem; font-weight: 600; } +.alert-row-title a { color: inherit; text-decoration: none; } +.alert-row-title a:hover { text-decoration: underline; } +.alert-severity { border: 1px solid currentColor; border-radius: 999px; padding: 0.125rem 0.5rem; font-size: 0.6875rem; font-weight: 600; text-transform: uppercase; } +.alert-severity--critical { color: #f85149; } +.alert-severity--high { color: #db6d28; } +.alert-severity--medium { color: #d29922; } +.alert-severity--low { color: #8b949e; } +.alert-row-description { margin: 0.5rem 0; color: #8b949e; font-size: 0.875rem; line-height: 1.5; } +.alert-row-meta { display: flex; flex-wrap: wrap; gap: 1rem; margin: 0.5rem 0 0; color: #8b949e; font-size: 0.75rem; } +.alert-row-meta strong { color: #c9d1d9; font-weight: 500; } +.alert-risk { min-width: 4rem; text-align: right; } +.alert-risk-label { display: block; color: #8b949e; font-size: 0.7rem; text-transform: uppercase; } +.alert-risk-value { font-size: 1.5rem; font-weight: 700; } +.alert-pagination { display: flex; align-items: center; justify-content: center; gap: 1rem; margin-top: 1rem; } +.alert-pagination button, +.alert-back { border: 1px solid #30363d; border-radius: 6px; background: #161b22; color: #c9d1d9; padding: 0.4rem 0.75rem; cursor: pointer; text-decoration: none; } +.alert-pagination button:disabled { cursor: not-allowed; opacity: 0.4; } +.alert-page-count { color: #8b949e; font-size: 0.8125rem; } +.alert-empty { padding: 2rem; border: 1px dashed #30363d; color: #8b949e; text-align: center; } + +.alert-detail-grid { display: grid; grid-template-columns: minmax(0, 2fr) minmax(220px, 1fr); gap: 1rem; } +.alert-panel { border: 1px solid #21262d; border-radius: 8px; background: #161b22; padding: 1rem; } +.alert-panel h2 { margin: 0 0 0.75rem; font-size: 1rem; } +.alert-explanation { color: #c9d1d9; line-height: 1.6; } +.alert-metadata { display: grid; gap: 0.75rem; margin: 0; } +.alert-metadata dt { color: #8b949e; font-size: 0.7rem; text-transform: uppercase; } +.alert-metadata dd { margin: 0.15rem 0 0; color: #e6edf3; font-size: 0.875rem; overflow-wrap: anywhere; } +.alert-score { display: flex; align-items: baseline; gap: 0.3rem; } +.alert-score strong { font-size: 2.5rem; } +.alert-score span { color: #8b949e; } +.alert-events { display: grid; gap: 0.75rem; list-style: none; margin: 0; padding: 0; } +.alert-event { border-left: 2px solid #30363d; padding-left: 0.75rem; } +.alert-event strong { font-size: 0.875rem; } +.alert-event p { margin: 0.25rem 0; color: #8b949e; font-size: 0.8125rem; } +.alert-event time { color: #6e7681; font-size: 0.75rem; } +.alert-not-found { padding: 2rem 0; } + +@media (max-width: 680px) { + .alerts-page { padding: 1rem; } + .alert-row { flex-direction: column; } + .alert-risk { text-align: left; } + .alert-detail-grid { grid-template-columns: 1fr; } +} diff --git a/apps/web/app/alerts/page.spec.tsx b/apps/web/app/alerts/page.spec.tsx new file mode 100644 index 0000000..8be9926 --- /dev/null +++ b/apps/web/app/alerts/page.spec.tsx @@ -0,0 +1,58 @@ +import { fireEvent, render, screen, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import AlertFeed from './page'; +import { MOCK_ALERTS } from './types'; +import { AlertDetails } from './[id]/page'; + +describe('AlertFeed', () => { + it('renders alert rows with severity badges', () => { + render(); + + expect(screen.getByRole('heading', { name: 'Alert Feed' })).toBeInTheDocument(); + const list = screen.getByRole('list', { name: 'Security alerts' }); + expect(list.children).toHaveLength(4); + expect(within(list).getByText('critical')).toBeInTheDocument(); + expect(within(list).getAllByText('high')).toHaveLength(2); + }); + + it('paginates through the alert listing', () => { + render(); + + const next = screen.getByRole('button', { name: 'Next' }); + fireEvent.click(next); + + expect(screen.getByText('Page 2 of 2')).toBeInTheDocument(); + expect(screen.getByText(MOCK_ALERTS[4].title)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled(); + }); + + it('filters by severity and resets to the first page', () => { + render(); + fireEvent.click(screen.getByRole('button', { name: 'Next' })); + fireEvent.change(screen.getByLabelText('Severity'), { target: { value: 'critical' } }); + + const list = screen.getByRole('list', { name: 'Security alerts' }); + expect(list.children).toHaveLength(1); + expect(within(list).getByText(MOCK_ALERTS[0].title)).toBeInTheDocument(); + expect(screen.getByText('Page 1 of 1')).toBeInTheDocument(); + }); +}); + +describe('AlertDetails', () => { + it('renders metadata, explanation, related events, and risk score', () => { + render(); + + expect(screen.getByRole('heading', { name: MOCK_ALERTS[0].title })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Detection explanation' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Alert metadata' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Related events' })).toBeInTheDocument(); + expect(screen.getByText('95')).toBeInTheDocument(); + expect(screen.getByText(MOCK_ALERTS[0].events[0].description)).toBeInTheDocument(); + }); + + it('renders a not-found state for an unknown alert', () => { + render(); + + expect(screen.getByRole('heading', { name: 'Alert not found' })).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/alerts/page.tsx b/apps/web/app/alerts/page.tsx new file mode 100644 index 0000000..69070f7 --- /dev/null +++ b/apps/web/app/alerts/page.tsx @@ -0,0 +1,123 @@ +'use client'; + +import { useState } from 'react'; +import { Alert, AlertSeverity, MOCK_ALERTS } from './types'; +import './alerts.css'; + +const PAGE_SIZE = 4; + +const formatDate = (value: string): string => + new Date(value).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' }); + +interface AlertFeedProps { + alerts?: Alert[]; +} + +export function AlertFeed({ alerts = MOCK_ALERTS }: AlertFeedProps) { + const [page, setPage] = useState(1); + const [severity, setSeverity] = useState('all'); + const filteredAlerts = + severity === 'all' ? alerts : alerts.filter(alert => alert.severity === severity); + const pageCount = Math.max(1, Math.ceil(filteredAlerts.length / PAGE_SIZE)); + const currentPage = Math.min(page, pageCount); + const visibleAlerts = filteredAlerts.slice( + (currentPage - 1) * PAGE_SIZE, + currentPage * PAGE_SIZE, + ); + const criticalCount = alerts.filter(alert => alert.severity === 'critical').length; + + const changeSeverity = (value: AlertSeverity | 'all') => { + setSeverity(value); + setPage(1); + }; + + return ( +
+
+
+

Alert Feed

+

Generated security alerts across monitored networks.

+
+
+
+
Total alerts
+
{alerts.length}
+
+
+
Critical
+
{criticalCount}
+
+
+
+ +
+ {' '} + +
+ + {visibleAlerts.length === 0 ? ( +

+ No alerts match this filter. +

+ ) : ( +
    + {visibleAlerts.map(alert => ( +
  • +
    +
    +

    + {alert.title} +

    + + {alert.severity} + +
    +

    {alert.explanation}

    +
    + + {alert.source} + + {alert.network} + {formatDate(alert.detectedAt)} +
    +
    +
    + Risk + {alert.riskScore} +
    +
  • + ))} +
+ )} + + +
+ ); +} + +export default AlertFeed; diff --git a/apps/web/app/alerts/types.ts b/apps/web/app/alerts/types.ts new file mode 100644 index 0000000..4a01a54 --- /dev/null +++ b/apps/web/app/alerts/types.ts @@ -0,0 +1,180 @@ +export type AlertSeverity = 'low' | 'medium' | 'high' | 'critical'; + +export interface AlertEvent { + id: string; + timestamp: string; + type: string; + description: string; + status: 'detected' | 'investigating' | 'resolved'; +} + +export interface Alert { + id: string; + title: string; + severity: AlertSeverity; + source: string; + network: string; + detectedAt: string; + riskScore: number; + explanation: string; + metadata: Array<{ label: string; value: string }>; + events: AlertEvent[]; +} + +export const ALERT_SEVERITIES: AlertSeverity[] = ['critical', 'high', 'medium', 'low']; + +export const MOCK_ALERTS: Alert[] = [ + { + id: 'alt-1001', + title: 'Ownership renouncement detected on Vault', + severity: 'critical', + source: 'Contract monitor', + network: 'Ethereum', + detectedAt: '2026-08-20T09:42:00.000Z', + riskScore: 95, + explanation: + 'The Vault contract renounced ownership without a scheduled governance proposal. This pattern can remove emergency controls and is consistent with a potential rug-pull precursor.', + metadata: [ + { label: 'Contract', value: '0x1a2b...3c4d' }, + { label: 'Function', value: 'renounceOwnership' }, + { label: 'Transaction', value: '0xabcd...ef01' }, + ], + events: [ + { + id: 'evt-1001-a', + timestamp: '2026-08-20T09:42:00.000Z', + type: 'Detection', + description: 'Ownership renouncement transaction confirmed on Ethereum.', + status: 'detected', + }, + { + id: 'evt-1001-b', + timestamp: '2026-08-20T09:47:00.000Z', + type: 'Correlation', + description: 'No matching governance proposal found in the monitored registry.', + status: 'investigating', + }, + ], + }, + { + id: 'alt-1002', + title: 'Unauthorized administrator change', + severity: 'high', + source: 'Governance monitor', + network: 'Soroban', + detectedAt: '2026-08-20T08:16:00.000Z', + riskScore: 82, + explanation: 'An administrator was changed outside the expected multisig approval path.', + metadata: [ + { label: 'Contract', value: 'Treasury' }, + { label: 'Function', value: 'set_admin' }, + { label: 'Transaction', value: '0x2345...6789' }, + ], + events: [ + { + id: 'evt-1002-a', + timestamp: '2026-08-20T08:16:00.000Z', + type: 'Detection', + description: 'set_admin call detected on the Treasury contract.', + status: 'detected', + }, + ], + }, + { + id: 'alt-1003', + title: 'Large liquidity withdrawal from pool', + severity: 'high', + source: 'Anomaly engine', + network: 'Polygon', + detectedAt: '2026-08-19T22:10:00.000Z', + riskScore: 88, + explanation: + 'A single transaction removed 25% of available pool liquidity, well outside its normal range.', + metadata: [ + { label: 'Pool', value: 'Stablecoin Pool' }, + { label: 'Amount', value: '25% of liquidity' }, + { label: 'Transaction', value: '0x3456...7890' }, + ], + events: [ + { + id: 'evt-1003-a', + timestamp: '2026-08-19T22:10:00.000Z', + type: 'Detection', + description: 'Liquidity withdrawal exceeded the configured anomaly threshold.', + status: 'detected', + }, + ], + }, + { + id: 'alt-1004', + title: 'Emergency pause triggered by multisig', + severity: 'medium', + source: 'Protocol monitor', + network: 'Ethereum', + detectedAt: '2026-08-19T14:05:00.000Z', + riskScore: 65, + explanation: 'A multisig paused the LendingPool after a sequence of unusual contract calls.', + metadata: [ + { label: 'Contract', value: 'LendingPool' }, + { label: 'Function', value: 'emergencyPause' }, + { label: 'Transaction', value: '0x4567...8901' }, + ], + events: [ + { + id: 'evt-1004-a', + timestamp: '2026-08-19T14:05:00.000Z', + type: 'Detection', + description: 'Emergency pause transaction confirmed.', + status: 'resolved', + }, + ], + }, + { + id: 'alt-1005', + title: 'Unexpected contract upgrade', + severity: 'medium', + source: 'Contract monitor', + network: 'Soroban', + detectedAt: '2026-08-19T09:45:00.000Z', + riskScore: 70, + explanation: 'A contract implementation changed without the expected maintenance window.', + metadata: [ + { label: 'Contract', value: 'Soroban DEX' }, + { label: 'Function', value: 'upgrade' }, + { label: 'Transaction', value: '0x5678...9012' }, + ], + events: [ + { + id: 'evt-1005-a', + timestamp: '2026-08-19T09:45:00.000Z', + type: 'Detection', + description: 'Implementation slot changed on the monitored contract.', + status: 'investigating', + }, + ], + }, + { + id: 'alt-1006', + title: 'High-frequency token minting pattern', + severity: 'low', + source: 'Behavioral analytics', + network: 'Ethereum', + detectedAt: '2026-08-18T18:30:00.000Z', + riskScore: 30, + explanation: 'The token contract minted more frequently than its historical baseline.', + metadata: [ + { label: 'Contract', value: 'Token contract' }, + { label: 'Function', value: 'mint' }, + { label: 'Transaction', value: '0x6789...0123' }, + ], + events: [ + { + id: 'evt-1006-a', + timestamp: '2026-08-18T18:30:00.000Z', + type: 'Detection', + description: 'Mint frequency returned to baseline after the initial spike.', + status: 'resolved', + }, + ], + }, +];