Skip to content

Commit c2e6b2b

Browse files
committed
feat(web): add alert feed and detail pages
1 parent 0005d3d commit c2e6b2b

5 files changed

Lines changed: 532 additions & 0 deletions

File tree

apps/web/app/alerts/[id]/page.tsx

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
import { Alert, MOCK_ALERTS } from '../types';
2+
import '../alerts.css';
3+
4+
interface AlertDetailsProps {
5+
alert?: Alert;
6+
}
7+
8+
export function AlertDetails({ alert }: AlertDetailsProps) {
9+
if (!alert) {
10+
return (
11+
<main className="alerts-page alert-not-found">
12+
<h1 className="alerts-title">Alert not found</h1>
13+
<a className="alert-back" href="/alerts">
14+
Back to alert feed
15+
</a>
16+
</main>
17+
);
18+
}
19+
20+
return (
21+
<main className="alerts-page">
22+
<header className="alert-detail-header">
23+
<div>
24+
<a className="alert-back" href="/alerts">
25+
Back to alert feed
26+
</a>
27+
<h1 className="alert-detail-title">{alert.title}</h1>
28+
<p className="alert-detail-subtitle">
29+
{alert.id} · {alert.source} · {alert.network}
30+
</p>
31+
</div>
32+
<span className={`alert-severity alert-severity--${alert.severity}`}>{alert.severity}</span>
33+
</header>
34+
35+
<div className="alert-detail-grid">
36+
<section className="alert-panel" aria-labelledby="alert-explanation-heading">
37+
<h2 id="alert-explanation-heading">Detection explanation</h2>
38+
<p className="alert-explanation">{alert.explanation}</p>
39+
</section>
40+
<section className="alert-panel" aria-labelledby="alert-risk-heading">
41+
<h2 id="alert-risk-heading">Risk score</h2>
42+
<div className="alert-score">
43+
<strong>{alert.riskScore}</strong>
44+
<span>/ 100</span>
45+
</div>
46+
</section>
47+
<section className="alert-panel" aria-labelledby="alert-metadata-heading">
48+
<h2 id="alert-metadata-heading">Alert metadata</h2>
49+
<dl className="alert-metadata">
50+
<div>
51+
<dt>Detected</dt>
52+
<dd>
53+
{new Date(alert.detectedAt).toLocaleString('en-US', {
54+
dateStyle: 'medium',
55+
timeStyle: 'short',
56+
})}
57+
</dd>
58+
</div>
59+
{alert.metadata.map(item => (
60+
<div key={item.label}>
61+
<dt>{item.label}</dt>
62+
<dd>{item.value}</dd>
63+
</div>
64+
))}
65+
</dl>
66+
</section>
67+
<section className="alert-panel" aria-labelledby="alert-events-heading">
68+
<h2 id="alert-events-heading">Related events</h2>
69+
<ol className="alert-events">
70+
{alert.events.map(event => (
71+
<li className="alert-event" key={event.id}>
72+
<strong>
73+
{event.type} · {event.status}
74+
</strong>
75+
<p>{event.description}</p>
76+
<time dateTime={event.timestamp}>
77+
{new Date(event.timestamp).toLocaleString('en-US', {
78+
dateStyle: 'medium',
79+
timeStyle: 'short',
80+
})}
81+
</time>
82+
</li>
83+
))}
84+
</ol>
85+
</section>
86+
</div>
87+
</main>
88+
);
89+
}
90+
91+
export default function AlertDetailsPage({ params }: { params: { id: string } }) {
92+
return <AlertDetails alert={MOCK_ALERTS.find(alert => alert.id === params.id)} />;
93+
}

apps/web/app/alerts/alerts.css

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
.alerts-page {
2+
min-height: 100%;
3+
padding: 1.5rem;
4+
color: #e6edf3;
5+
background: #0d1117;
6+
}
7+
8+
.alerts-header,
9+
.alert-detail-header {
10+
display: flex;
11+
flex-wrap: wrap;
12+
align-items: flex-start;
13+
justify-content: space-between;
14+
gap: 1rem;
15+
border-bottom: 1px solid #21262d;
16+
padding-bottom: 1rem;
17+
margin-bottom: 1.25rem;
18+
}
19+
20+
.alerts-title,
21+
.alert-detail-title { margin: 0; font-size: 1.5rem; font-weight: 600; }
22+
.alerts-subtitle,
23+
.alert-detail-subtitle { margin: 0.25rem 0 0; color: #8b949e; font-size: 0.875rem; }
24+
.alerts-summary { display: flex; gap: 1.25rem; margin: 0; }
25+
.alerts-summary div { text-align: right; }
26+
.alerts-summary dt { color: #8b949e; font-size: 0.7rem; text-transform: uppercase; }
27+
.alerts-summary dd { margin: 0; font-size: 1.35rem; font-weight: 600; }
28+
.alerts-summary .critical { color: #f85149; }
29+
30+
.alert-list { display: grid; gap: 0.75rem; list-style: none; margin: 0; padding: 0; }
31+
.alert-row { display: flex; gap: 1rem; padding: 1rem; border: 1px solid #21262d; border-radius: 8px; background: #161b22; }
32+
.alert-row:hover { border-color: #58a6ff; }
33+
.alert-row-main { min-width: 0; flex: 1; }
34+
.alert-row-heading { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem; }
35+
.alert-row-title { margin: 0; font-size: 1rem; font-weight: 600; }
36+
.alert-row-title a { color: inherit; text-decoration: none; }
37+
.alert-row-title a:hover { text-decoration: underline; }
38+
.alert-severity { border: 1px solid currentColor; border-radius: 999px; padding: 0.125rem 0.5rem; font-size: 0.6875rem; font-weight: 600; text-transform: uppercase; }
39+
.alert-severity--critical { color: #f85149; }
40+
.alert-severity--high { color: #db6d28; }
41+
.alert-severity--medium { color: #d29922; }
42+
.alert-severity--low { color: #8b949e; }
43+
.alert-row-description { margin: 0.5rem 0; color: #8b949e; font-size: 0.875rem; line-height: 1.5; }
44+
.alert-row-meta { display: flex; flex-wrap: wrap; gap: 1rem; margin: 0.5rem 0 0; color: #8b949e; font-size: 0.75rem; }
45+
.alert-row-meta strong { color: #c9d1d9; font-weight: 500; }
46+
.alert-risk { min-width: 4rem; text-align: right; }
47+
.alert-risk-label { display: block; color: #8b949e; font-size: 0.7rem; text-transform: uppercase; }
48+
.alert-risk-value { font-size: 1.5rem; font-weight: 700; }
49+
.alert-pagination { display: flex; align-items: center; justify-content: center; gap: 1rem; margin-top: 1rem; }
50+
.alert-pagination button,
51+
.alert-back { border: 1px solid #30363d; border-radius: 6px; background: #161b22; color: #c9d1d9; padding: 0.4rem 0.75rem; cursor: pointer; text-decoration: none; }
52+
.alert-pagination button:disabled { cursor: not-allowed; opacity: 0.4; }
53+
.alert-page-count { color: #8b949e; font-size: 0.8125rem; }
54+
.alert-empty { padding: 2rem; border: 1px dashed #30363d; color: #8b949e; text-align: center; }
55+
56+
.alert-detail-grid { display: grid; grid-template-columns: minmax(0, 2fr) minmax(220px, 1fr); gap: 1rem; }
57+
.alert-panel { border: 1px solid #21262d; border-radius: 8px; background: #161b22; padding: 1rem; }
58+
.alert-panel h2 { margin: 0 0 0.75rem; font-size: 1rem; }
59+
.alert-explanation { color: #c9d1d9; line-height: 1.6; }
60+
.alert-metadata { display: grid; gap: 0.75rem; margin: 0; }
61+
.alert-metadata dt { color: #8b949e; font-size: 0.7rem; text-transform: uppercase; }
62+
.alert-metadata dd { margin: 0.15rem 0 0; color: #e6edf3; font-size: 0.875rem; overflow-wrap: anywhere; }
63+
.alert-score { display: flex; align-items: baseline; gap: 0.3rem; }
64+
.alert-score strong { font-size: 2.5rem; }
65+
.alert-score span { color: #8b949e; }
66+
.alert-events { display: grid; gap: 0.75rem; list-style: none; margin: 0; padding: 0; }
67+
.alert-event { border-left: 2px solid #30363d; padding-left: 0.75rem; }
68+
.alert-event strong { font-size: 0.875rem; }
69+
.alert-event p { margin: 0.25rem 0; color: #8b949e; font-size: 0.8125rem; }
70+
.alert-event time { color: #6e7681; font-size: 0.75rem; }
71+
.alert-not-found { padding: 2rem 0; }
72+
73+
@media (max-width: 680px) {
74+
.alerts-page { padding: 1rem; }
75+
.alert-row { flex-direction: column; }
76+
.alert-risk { text-align: left; }
77+
.alert-detail-grid { grid-template-columns: 1fr; }
78+
}

apps/web/app/alerts/page.spec.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { fireEvent, render, screen, within } from '@testing-library/react';
2+
import '@testing-library/jest-dom';
3+
import AlertFeed from './page';
4+
import { MOCK_ALERTS } from './types';
5+
import { AlertDetails } from './[id]/page';
6+
7+
describe('AlertFeed', () => {
8+
it('renders alert rows with severity badges', () => {
9+
render(<AlertFeed />);
10+
11+
expect(screen.getByRole('heading', { name: 'Alert Feed' })).toBeInTheDocument();
12+
const list = screen.getByRole('list', { name: 'Security alerts' });
13+
expect(list.children).toHaveLength(4);
14+
expect(within(list).getByText('critical')).toBeInTheDocument();
15+
expect(within(list).getAllByText('high')).toHaveLength(2);
16+
});
17+
18+
it('paginates through the alert listing', () => {
19+
render(<AlertFeed />);
20+
21+
const next = screen.getByRole('button', { name: 'Next' });
22+
fireEvent.click(next);
23+
24+
expect(screen.getByText('Page 2 of 2')).toBeInTheDocument();
25+
expect(screen.getByText(MOCK_ALERTS[4].title)).toBeInTheDocument();
26+
expect(screen.getByRole('button', { name: 'Next' })).toBeDisabled();
27+
});
28+
29+
it('filters by severity and resets to the first page', () => {
30+
render(<AlertFeed />);
31+
fireEvent.click(screen.getByRole('button', { name: 'Next' }));
32+
fireEvent.change(screen.getByLabelText('Severity'), { target: { value: 'critical' } });
33+
34+
const list = screen.getByRole('list', { name: 'Security alerts' });
35+
expect(list.children).toHaveLength(1);
36+
expect(within(list).getByText(MOCK_ALERTS[0].title)).toBeInTheDocument();
37+
expect(screen.getByText('Page 1 of 1')).toBeInTheDocument();
38+
});
39+
});
40+
41+
describe('AlertDetails', () => {
42+
it('renders metadata, explanation, related events, and risk score', () => {
43+
render(<AlertDetails alert={MOCK_ALERTS[0]} />);
44+
45+
expect(screen.getByRole('heading', { name: MOCK_ALERTS[0].title })).toBeInTheDocument();
46+
expect(screen.getByRole('heading', { name: 'Detection explanation' })).toBeInTheDocument();
47+
expect(screen.getByRole('heading', { name: 'Alert metadata' })).toBeInTheDocument();
48+
expect(screen.getByRole('heading', { name: 'Related events' })).toBeInTheDocument();
49+
expect(screen.getByText('95')).toBeInTheDocument();
50+
expect(screen.getByText(MOCK_ALERTS[0].events[0].description)).toBeInTheDocument();
51+
});
52+
53+
it('renders a not-found state for an unknown alert', () => {
54+
render(<AlertDetails />);
55+
56+
expect(screen.getByRole('heading', { name: 'Alert not found' })).toBeInTheDocument();
57+
});
58+
});

apps/web/app/alerts/page.tsx

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
'use client';
2+
3+
import { useState } from 'react';
4+
import { Alert, AlertSeverity, MOCK_ALERTS } from './types';
5+
import './alerts.css';
6+
7+
const PAGE_SIZE = 4;
8+
9+
const formatDate = (value: string): string =>
10+
new Date(value).toLocaleString('en-US', { dateStyle: 'medium', timeStyle: 'short' });
11+
12+
interface AlertFeedProps {
13+
alerts?: Alert[];
14+
}
15+
16+
export function AlertFeed({ alerts = MOCK_ALERTS }: AlertFeedProps) {
17+
const [page, setPage] = useState(1);
18+
const [severity, setSeverity] = useState<AlertSeverity | 'all'>('all');
19+
const filteredAlerts =
20+
severity === 'all' ? alerts : alerts.filter(alert => alert.severity === severity);
21+
const pageCount = Math.max(1, Math.ceil(filteredAlerts.length / PAGE_SIZE));
22+
const currentPage = Math.min(page, pageCount);
23+
const visibleAlerts = filteredAlerts.slice(
24+
(currentPage - 1) * PAGE_SIZE,
25+
currentPage * PAGE_SIZE,
26+
);
27+
const criticalCount = alerts.filter(alert => alert.severity === 'critical').length;
28+
29+
const changeSeverity = (value: AlertSeverity | 'all') => {
30+
setSeverity(value);
31+
setPage(1);
32+
};
33+
34+
return (
35+
<main className="alerts-page">
36+
<header className="alerts-header">
37+
<div>
38+
<h1 className="alerts-title">Alert Feed</h1>
39+
<p className="alerts-subtitle">Generated security alerts across monitored networks.</p>
40+
</div>
41+
<dl className="alerts-summary" aria-label="Alert summary">
42+
<div>
43+
<dt>Total alerts</dt>
44+
<dd>{alerts.length}</dd>
45+
</div>
46+
<div className="critical">
47+
<dt>Critical</dt>
48+
<dd>{criticalCount}</dd>
49+
</div>
50+
</dl>
51+
</header>
52+
53+
<div className="alert-filter" role="group" aria-label="Filter alerts by severity">
54+
<label htmlFor="alert-severity">Severity</label>{' '}
55+
<select
56+
id="alert-severity"
57+
value={severity}
58+
onChange={event => changeSeverity(event.target.value as AlertSeverity | 'all')}
59+
>
60+
<option value="all">All severities</option>
61+
{(['critical', 'high', 'medium', 'low'] as AlertSeverity[]).map(value => (
62+
<option key={value} value={value}>
63+
{value}
64+
</option>
65+
))}
66+
</select>
67+
</div>
68+
69+
{visibleAlerts.length === 0 ? (
70+
<p className="alert-empty" role="status">
71+
No alerts match this filter.
72+
</p>
73+
) : (
74+
<ul className="alert-list" aria-label="Security alerts">
75+
{visibleAlerts.map(alert => (
76+
<li className="alert-row" key={alert.id}>
77+
<div className="alert-row-main">
78+
<div className="alert-row-heading">
79+
<h2 className="alert-row-title">
80+
<a href={`/alerts/${alert.id}`}>{alert.title}</a>
81+
</h2>
82+
<span className={`alert-severity alert-severity--${alert.severity}`}>
83+
{alert.severity}
84+
</span>
85+
</div>
86+
<p className="alert-row-description">{alert.explanation}</p>
87+
<div className="alert-row-meta">
88+
<span>
89+
<strong>{alert.source}</strong>
90+
</span>
91+
<span>{alert.network}</span>
92+
<span>{formatDate(alert.detectedAt)}</span>
93+
</div>
94+
</div>
95+
<div className="alert-risk" aria-label={`Risk score ${alert.riskScore} out of 100`}>
96+
<span className="alert-risk-label">Risk</span>
97+
<span className="alert-risk-value">{alert.riskScore}</span>
98+
</div>
99+
</li>
100+
))}
101+
</ul>
102+
)}
103+
104+
<nav className="alert-pagination" aria-label="Alert pagination">
105+
<button type="button" onClick={() => setPage(currentPage - 1)} disabled={currentPage === 1}>
106+
Previous
107+
</button>
108+
<span className="alert-page-count" aria-live="polite">
109+
Page {currentPage} of {pageCount}
110+
</span>
111+
<button
112+
type="button"
113+
onClick={() => setPage(currentPage + 1)}
114+
disabled={currentPage === pageCount}
115+
>
116+
Next
117+
</button>
118+
</nav>
119+
</main>
120+
);
121+
}
122+
123+
export default AlertFeed;

0 commit comments

Comments
 (0)