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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions apps/web/app/alerts/[id]/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<main className="alerts-page alert-not-found">
<h1 className="alerts-title">Alert not found</h1>
<a className="alert-back" href="/alerts">
Back to alert feed
</a>
</main>
);
}

return (
<main className="alerts-page">
<header className="alert-detail-header">
<div>
<a className="alert-back" href="/alerts">
Back to alert feed
</a>
<h1 className="alert-detail-title">{alert.title}</h1>
<p className="alert-detail-subtitle">
{alert.id} · {alert.source} · {alert.network}
</p>
</div>
<span className={`alert-severity alert-severity--${alert.severity}`}>{alert.severity}</span>
</header>

<div className="alert-detail-grid">
<section className="alert-panel" aria-labelledby="alert-explanation-heading">
<h2 id="alert-explanation-heading">Detection explanation</h2>
<p className="alert-explanation">{alert.explanation}</p>
</section>
<section className="alert-panel" aria-labelledby="alert-risk-heading">
<h2 id="alert-risk-heading">Risk score</h2>
<div className="alert-score">
<strong>{alert.riskScore}</strong>
<span>/ 100</span>
</div>
</section>
<section className="alert-panel" aria-labelledby="alert-metadata-heading">
<h2 id="alert-metadata-heading">Alert metadata</h2>
<dl className="alert-metadata">
<div>
<dt>Detected</dt>
<dd>
{new Date(alert.detectedAt).toLocaleString('en-US', {
dateStyle: 'medium',
timeStyle: 'short',
})}
</dd>
</div>
{alert.metadata.map(item => (
<div key={item.label}>
<dt>{item.label}</dt>
<dd>{item.value}</dd>
</div>
))}
</dl>
</section>
<section className="alert-panel" aria-labelledby="alert-events-heading">
<h2 id="alert-events-heading">Related events</h2>
<ol className="alert-events">
{alert.events.map(event => (
<li className="alert-event" key={event.id}>
<strong>
{event.type} · {event.status}
</strong>
<p>{event.description}</p>
<time dateTime={event.timestamp}>
{new Date(event.timestamp).toLocaleString('en-US', {
dateStyle: 'medium',
timeStyle: 'short',
})}
</time>
</li>
))}
</ol>
</section>
</div>
</main>
);
}

export default function AlertDetailsPage({ params }: { params: { id: string } }) {
return <AlertDetails alert={MOCK_ALERTS.find(alert => alert.id === params.id)} />;
}
78 changes: 78 additions & 0 deletions apps/web/app/alerts/alerts.css
Original file line number Diff line number Diff line change
@@ -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; }
}
58 changes: 58 additions & 0 deletions apps/web/app/alerts/page.spec.tsx
Original file line number Diff line number Diff line change
@@ -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(<AlertFeed />);

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(<AlertFeed />);

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(<AlertFeed />);
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(<AlertDetails alert={MOCK_ALERTS[0]} />);

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(<AlertDetails />);

expect(screen.getByRole('heading', { name: 'Alert not found' })).toBeInTheDocument();
});
});
123 changes: 123 additions & 0 deletions apps/web/app/alerts/page.tsx
Original file line number Diff line number Diff line change
@@ -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<AlertSeverity | 'all'>('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 (
<main className="alerts-page">
<header className="alerts-header">
<div>
<h1 className="alerts-title">Alert Feed</h1>
<p className="alerts-subtitle">Generated security alerts across monitored networks.</p>
</div>
<dl className="alerts-summary" aria-label="Alert summary">
<div>
<dt>Total alerts</dt>
<dd>{alerts.length}</dd>
</div>
<div className="critical">
<dt>Critical</dt>
<dd>{criticalCount}</dd>
</div>
</dl>
</header>

<div className="alert-filter" role="group" aria-label="Filter alerts by severity">
<label htmlFor="alert-severity">Severity</label>{' '}
<select
id="alert-severity"
value={severity}
onChange={event => changeSeverity(event.target.value as AlertSeverity | 'all')}
>
<option value="all">All severities</option>
{(['critical', 'high', 'medium', 'low'] as AlertSeverity[]).map(value => (
<option key={value} value={value}>
{value}
</option>
))}
</select>
</div>

{visibleAlerts.length === 0 ? (
<p className="alert-empty" role="status">
No alerts match this filter.
</p>
) : (
<ul className="alert-list" aria-label="Security alerts">
{visibleAlerts.map(alert => (
<li className="alert-row" key={alert.id}>
<div className="alert-row-main">
<div className="alert-row-heading">
<h2 className="alert-row-title">
<a href={`/alerts/${alert.id}`}>{alert.title}</a>
</h2>
<span className={`alert-severity alert-severity--${alert.severity}`}>
{alert.severity}
</span>
</div>
<p className="alert-row-description">{alert.explanation}</p>
<div className="alert-row-meta">
<span>
<strong>{alert.source}</strong>
</span>
<span>{alert.network}</span>
<span>{formatDate(alert.detectedAt)}</span>
</div>
</div>
<div className="alert-risk" aria-label={`Risk score ${alert.riskScore} out of 100`}>
<span className="alert-risk-label">Risk</span>
<span className="alert-risk-value">{alert.riskScore}</span>
</div>
</li>
))}
</ul>
)}

<nav className="alert-pagination" aria-label="Alert pagination">
<button type="button" onClick={() => setPage(currentPage - 1)} disabled={currentPage === 1}>
Previous
</button>
<span className="alert-page-count" aria-live="polite">
Page {currentPage} of {pageCount}
</span>
<button
type="button"
onClick={() => setPage(currentPage + 1)}
disabled={currentPage === pageCount}
>
Next
</button>
</nav>
</main>
);
}

export default AlertFeed;
Loading
Loading