diff --git a/apps/web/app/incidents/IncidentFilters.tsx b/apps/web/app/incidents/IncidentFilters.tsx new file mode 100644 index 0000000..3f4539a --- /dev/null +++ b/apps/web/app/incidents/IncidentFilters.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { + Incident, + IncidentPriority, + IncidentStatus, + INCIDENT_PRIORITIES, + INCIDENT_STATUSES, + PRIORITY_LABELS, +} from './types'; + +export type StatusFilter = IncidentStatus | 'all'; +export type PriorityFilter = IncidentPriority | 'all'; + +interface IncidentFiltersProps { + incidents: Incident[]; + status: StatusFilter; + priority: PriorityFilter; + onStatusChange: (status: StatusFilter) => void; + onPriorityChange: (priority: PriorityFilter) => void; +} + +/** + * Status and priority filters. + * + * Counts are computed from the unfiltered incident set so that each control + * shows how many incidents it would reveal, rather than how many are currently + * on screen. A filter that would show nothing is disabled instead of hidden, so + * the set of controls stays stable as the data changes. + */ +export const IncidentFilters: React.FC = ({ + incidents, + status, + priority, + onStatusChange, + onPriorityChange, +}) => { + const countByStatus = (value: IncidentStatus) => + incidents.filter(incident => incident.status === value).length; + + const countByPriority = (value: IncidentPriority) => + incidents.filter(incident => incident.priority === value).length; + + return ( +
+
+ Status + + {INCIDENT_STATUSES.map(value => { + const count = countByStatus(value); + return ( + + ); + })} +
+ +
+ Priority + + {INCIDENT_PRIORITIES.map(value => { + const count = countByPriority(value); + return ( + + ); + })} +
+
+ ); +}; + +export default IncidentFilters; diff --git a/apps/web/app/incidents/IncidentList.tsx b/apps/web/app/incidents/IncidentList.tsx new file mode 100644 index 0000000..dc92d91 --- /dev/null +++ b/apps/web/app/incidents/IncidentList.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import { Incident, PRIORITY_LABELS, isTerminal } from './types'; + +interface IncidentListProps { + incidents: Incident[]; +} + +const formatTimestamp = (iso: string): string => { + const date = new Date(iso); + return Number.isNaN(date.getTime()) ? iso : date.toISOString().replace('T', ' ').slice(0, 16); +}; + +/** + * The incident listing. + * + * Each row leads with a priority indicator: a colour-coded dot plus its literal + * priority text. Colour alone would leave the most important signal on the page + * unreadable to anyone who cannot distinguish the hues, so the label is always + * rendered and the dot carries no information of its own. + */ +export const IncidentList: React.FC = ({ incidents }) => { + if (incidents.length === 0) { + return ( +
+

No incidents match these filters

+

Clear a filter to widen the search.

+
+ ); + } + + return ( + + ); +}; + +export default IncidentList; diff --git a/apps/web/app/incidents/incidents.css b/apps/web/app/incidents/incidents.css new file mode 100644 index 0000000..118fa24 --- /dev/null +++ b/apps/web/app/incidents/incidents.css @@ -0,0 +1,302 @@ +/* Incident dashboard — palette follows the dark analyst surfaces used by the + other workspaces in apps/web/app. Status and priority colours are paired with + text labels everywhere they appear; colour is never the only signal. */ + +.inc-dashboard { + padding: 1.5rem; + color: #e6edf3; + background: #0d1117; + min-height: 100%; +} + +.inc-header { + display: flex; + flex-wrap: wrap; + gap: 1rem; + align-items: flex-start; + justify-content: space-between; + border-bottom: 1px solid #21262d; + padding-bottom: 1rem; + margin-bottom: 1.25rem; +} + +.inc-title { + margin: 0; + font-size: 1.5rem; + font-weight: 600; +} + +.inc-subtitle { + margin: 0.25rem 0 0; + color: #8b949e; + font-size: 0.875rem; +} + +.inc-summary { + display: flex; + gap: 1.5rem; + margin: 0; +} + +.inc-summary-item { + text-align: right; +} + +.inc-summary-item dt { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #8b949e; +} + +.inc-summary-item dd { + margin: 0; + font-size: 1.5rem; + font-weight: 600; +} + +.inc-summary-item--urgent dd { + color: #f85149; +} + +/* -- filters -------------------------------------------------------------- */ + +.inc-filters { + display: flex; + flex-direction: column; + gap: 0.75rem; + margin-bottom: 1rem; +} + +.inc-filter-group { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.inc-filter-legend { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; + color: #8b949e; + min-width: 4.5rem; +} + +.inc-chip { + display: inline-flex; + align-items: center; + gap: 0.375rem; + padding: 0.25rem 0.625rem; + border: 1px solid #30363d; + border-radius: 999px; + background: #161b22; + color: #c9d1d9; + font-size: 0.8125rem; + text-transform: capitalize; + cursor: pointer; +} + +.inc-chip:hover:not(:disabled) { + border-color: #8b949e; +} + +.inc-chip:disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.inc-chip--active { + background: #1f6feb; + border-color: #1f6feb; + color: #ffffff; +} + +.inc-chip-count { + font-variant-numeric: tabular-nums; + opacity: 0.75; +} + +/* -- results bar ---------------------------------------------------------- */ + +.inc-results { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 0.75rem; +} + +.inc-results-count { + margin: 0; + color: #8b949e; + font-size: 0.8125rem; +} + +.inc-clear { + background: none; + border: 1px solid #30363d; + border-radius: 6px; + color: #c9d1d9; + padding: 0.25rem 0.625rem; + font-size: 0.8125rem; + cursor: pointer; +} + +/* -- listing -------------------------------------------------------------- */ + +.inc-list { + list-style: none; + margin: 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.inc-row { + display: flex; + gap: 1rem; + padding: 1rem; + border: 1px solid #21262d; + border-radius: 8px; + background: #161b22; +} + +.inc-row--terminal { + opacity: 0.65; +} + +.inc-row-priority { + display: flex; + align-items: center; + gap: 0.5rem; + min-width: 8.5rem; +} + +.inc-priority-dot { + width: 0.625rem; + height: 0.625rem; + border-radius: 50%; + flex-shrink: 0; +} + +.inc-priority-dot--p1 { background: #f85149; } +.inc-priority-dot--p2 { background: #db6d28; } +.inc-priority-dot--p3 { background: #d29922; } +.inc-priority-dot--p4 { background: #8b949e; } + +.inc-priority-label { + font-size: 0.75rem; + font-weight: 600; + white-space: nowrap; +} + +.inc-row-main { + flex: 1; + min-width: 0; +} + +.inc-row-heading { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; +} + +.inc-row-title { + margin: 0; + font-size: 1rem; + font-weight: 600; +} + +.inc-status, +.inc-severity { + padding: 0.125rem 0.5rem; + border-radius: 999px; + font-size: 0.6875rem; + text-transform: uppercase; + letter-spacing: 0.03em; + border: 1px solid currentColor; +} + +.inc-status--new, +.inc-status--reopened { color: #58a6ff; } +.inc-status--open, +.inc-status--acknowledged { color: #d29922; } +.inc-status--investigating, +.inc-status--contained { color: #db6d28; } +.inc-status--resolved, +.inc-status--closed { color: #3fb950; } + +.inc-severity--critical { color: #f85149; } +.inc-severity--high { color: #db6d28; } +.inc-severity--medium { color: #d29922; } +.inc-severity--low { color: #8b949e; } + +.inc-row-description { + margin: 0.5rem 0; + color: #8b949e; + font-size: 0.875rem; + line-height: 1.5; +} + +.inc-row-meta { + display: flex; + flex-wrap: wrap; + gap: 1rem; + margin: 0.5rem 0 0; +} + +.inc-meta-item { + display: flex; + gap: 0.375rem; + font-size: 0.75rem; +} + +.inc-meta-item dt { + color: #6e7681; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.inc-meta-item dd { + margin: 0; + color: #c9d1d9; +} + +.inc-tags { + display: flex; + flex-wrap: wrap; + gap: 0.375rem; + list-style: none; + margin: 0.625rem 0 0; + padding: 0; +} + +.inc-tag { + padding: 0.0625rem 0.4375rem; + border-radius: 4px; + background: #21262d; + color: #8b949e; + font-size: 0.6875rem; +} + +/* -- empty state ---------------------------------------------------------- */ + +.inc-empty { + padding: 2.5rem 1rem; + text-align: center; + border: 1px dashed #30363d; + border-radius: 8px; +} + +.inc-empty-title { + margin: 0; + font-weight: 600; +} + +.inc-empty-hint { + margin: 0.375rem 0 0; + color: #8b949e; + font-size: 0.875rem; +} diff --git a/apps/web/app/incidents/page.spec.tsx b/apps/web/app/incidents/page.spec.tsx new file mode 100644 index 0000000..89810e2 --- /dev/null +++ b/apps/web/app/incidents/page.spec.tsx @@ -0,0 +1,167 @@ +import React from 'react'; +import { render, screen, fireEvent, within } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { IncidentDashboard } from './page'; +import { Incident, MOCK_INCIDENTS } from './types'; + +const getList = () => screen.getByRole('list', { name: /^incidents$/i }); + +// Each row carries a nested tag list, so `getAllByRole('listitem')` would count +// tag chips as rows. Only direct children of the incident list are rows. +const getRows = (): HTMLElement[] => + Array.from(getList().children).filter((el): el is HTMLElement => el.tagName === 'LI'); + +const statusButton = (name: RegExp) => + within(screen.getByRole('group', { name: /filter by status/i })).getByRole('button', { + name, + }); + +const priorityButton = (name: RegExp) => + within(screen.getByRole('group', { name: /filter by priority/i })).getByRole('button', { + name, + }); + +describe('IncidentDashboard', () => { + beforeEach(() => { + render(); + }); + + it('renders the dashboard heading', () => { + expect( + screen.getByRole('heading', { name: /incident dashboard/i, level: 1 }), + ).toBeInTheDocument(); + }); + + it('lists every incident by default', () => { + expect(getRows()).toHaveLength(MOCK_INCIDENTS.length); + }); + + it('shows a title for each incident', () => { + MOCK_INCIDENTS.forEach(incident => { + expect(screen.getByText(incident.title)).toBeInTheDocument(); + }); + }); + + it('renders a status indicator for every incident', () => { + const rows = getRows(); + rows.forEach((row, index) => { + expect(within(row).getByText(MOCK_INCIDENTS[index].status)).toBeInTheDocument(); + }); + }); + + it('renders a readable priority label, not colour alone', () => { + const firstRow = getRows()[0]; + // MOCK_INCIDENTS[0] is p1; the label must carry the meaning in text. + expect(within(firstRow).getByText(/P1 — Critical/)).toBeInTheDocument(); + }); + + it('filters the listing by status', () => { + fireEvent.click(statusButton(/^investigating/i)); + + const expected = MOCK_INCIDENTS.filter(i => i.status === 'investigating'); + expect(getRows()).toHaveLength(expected.length); + expect(screen.getByText(expected[0].title)).toBeInTheDocument(); + }); + + it('filters the listing by priority', () => { + fireEvent.click(priorityButton(/^P1/)); + + const expected = MOCK_INCIDENTS.filter(i => i.priority === 'p1'); + expect(getRows()).toHaveLength(expected.length); + }); + + it('applies status and priority filters together', () => { + fireEvent.click(statusButton(/^reopened/i)); + fireEvent.click(priorityButton(/^P1/)); + + const expected = MOCK_INCIDENTS.filter(i => i.status === 'reopened' && i.priority === 'p1'); + expect(getRows()).toHaveLength(expected.length); + }); + + it('marks the selected filter as pressed', () => { + const button = statusButton(/^new/i); + expect(button).toHaveAttribute('aria-pressed', 'false'); + + fireEvent.click(button); + expect(button).toHaveAttribute('aria-pressed', 'true'); + }); + + it('reports how many incidents are showing', () => { + fireEvent.click(priorityButton(/^P1/)); + + const expected = MOCK_INCIDENTS.filter(i => i.priority === 'p1').length; + expect( + screen.getByText(new RegExp(`showing ${expected} of ${MOCK_INCIDENTS.length}`, 'i')), + ).toBeInTheDocument(); + }); + + it('restores the full listing when filters are cleared', () => { + fireEvent.click(statusButton(/^new/i)); + expect(getRows()).toHaveLength(1); + + fireEvent.click(screen.getByRole('button', { name: /clear filters/i })); + expect(getRows()).toHaveLength(MOCK_INCIDENTS.length); + }); + + it('hides the clear control until a filter is applied', () => { + expect(screen.queryByRole('button', { name: /clear filters/i })).not.toBeInTheDocument(); + + fireEvent.click(statusButton(/^new/i)); + expect(screen.getByRole('button', { name: /clear filters/i })).toBeInTheDocument(); + }); + + it('disables a status filter that would match nothing', () => { + // No mock incident is in the "acknowledged and P4" state, but each filter + // is counted against the whole set, so a status with zero incidents is the + // one that must be disabled. 'contained' has one; pick a genuinely empty one. + const emptyStatuses = ['open', 'acknowledged', 'contained'].filter(status => + MOCK_INCIDENTS.every(i => i.status !== status), + ); + emptyStatuses.forEach(status => { + expect(statusButton(new RegExp(`^${status}`, 'i'))).toBeDisabled(); + }); + }); +}); + +describe('IncidentDashboard with injected data', () => { + const incidents: Incident[] = [ + { + id: 'inc-x1', + title: 'Only incident', + description: 'Sole record.', + status: 'new', + severity: 'low', + priority: 'p4', + tags: [], + createdAt: '2026-08-19T00:00:00.000Z', + updatedAt: '2026-08-19T00:00:00.000Z', + }, + ]; + + it('shows an empty state when filters match nothing', () => { + render(); + + fireEvent.click( + within(screen.getByRole('group', { name: /filter by priority/i })).getByRole('button', { + name: /^P4/, + }), + ); + expect(screen.getByText('Only incident')).toBeInTheDocument(); + + fireEvent.click( + within(screen.getByRole('group', { name: /filter by status/i })).getByRole('button', { + name: /^new/i, + }), + ); + expect(screen.getByText('Only incident')).toBeInTheDocument(); + }); + + it('counts unassigned incidents in the summary', () => { + render(); + + const summary = screen.getByRole('group', { name: /incident summary/i }); + const unassigned = within(summary).getByText('Unassigned').closest('div'); + expect(unassigned).not.toBeNull(); + expect(within(unassigned as HTMLElement).getByText('1')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/app/incidents/page.tsx b/apps/web/app/incidents/page.tsx new file mode 100644 index 0000000..8151096 --- /dev/null +++ b/apps/web/app/incidents/page.tsx @@ -0,0 +1,112 @@ +import React, { useMemo, useState } from 'react'; +import { IncidentFilters, PriorityFilter, StatusFilter } from './IncidentFilters'; +import { IncidentList } from './IncidentList'; +import { Incident, INCIDENT_PRIORITIES, MOCK_INCIDENTS, isTerminal } from './types'; +import './incidents.css'; + +interface IncidentDashboardProps { + /** Injectable for tests and for wiring to a real data source later. */ + incidents?: Incident[]; +} + +/** + * Incident dashboard — a dedicated workspace for active investigations. + * + * Data is supplied by the caller so the view stays a pure function of its + * props; it falls back to the mock set, matching how the other workspaces in + * `apps/web/app` currently source their data. + */ +export const IncidentDashboard: React.FC = ({ + incidents = MOCK_INCIDENTS, +}) => { + const [status, setStatus] = useState('all'); + const [priority, setPriority] = useState('all'); + + const visibleIncidents = useMemo( + () => + incidents.filter(incident => { + const statusMatches = status === 'all' || incident.status === status; + const priorityMatches = priority === 'all' || incident.priority === priority; + return statusMatches && priorityMatches; + }), + [incidents, status, priority], + ); + + const activeCount = useMemo( + () => incidents.filter(incident => !isTerminal(incident.status)).length, + [incidents], + ); + + const urgentCount = useMemo( + () => + incidents.filter(incident => incident.priority === 'p1' && !isTerminal(incident.status)) + .length, + [incidents], + ); + + const unassignedCount = useMemo( + () => incidents.filter(incident => !incident.assignedTo && !isTerminal(incident.status)).length, + [incidents], + ); + + const filtersApplied = status !== 'all' || priority !== 'all'; + + const clearFilters = () => { + setStatus('all'); + setPriority('all'); + }; + + return ( +
+
+
+

Incident Dashboard

+

A dedicated workspace for active security investigations.

+
+ + {/* A bare
exposes no ARIA role, so the label would be dropped by + assistive tech. role="group" makes the grouping addressable. */} +
+
+
Active
+
{activeCount}
+
+
+
P1 open
+
{urgentCount}
+
+
+
Unassigned
+
{unassignedCount}
+
+
+
+ + + +
+

+ Showing {visibleIncidents.length} of {incidents.length} incidents +

+ {filtersApplied && ( + + )} +
+ + +
+ ); +}; + +export default IncidentDashboard; + +/** Re-exported so callers can render a priority legend without reaching into types. */ +export { INCIDENT_PRIORITIES }; diff --git a/apps/web/app/incidents/types.ts b/apps/web/app/incidents/types.ts new file mode 100644 index 0000000..07db59d --- /dev/null +++ b/apps/web/app/incidents/types.ts @@ -0,0 +1,186 @@ +/** + * Incident types for the investigation dashboard. + * + * The unions below mirror the string columns on the `Incident` Prisma model + * (`prisma/schema.prisma`) so that the dashboard cannot drift from what the + * database can actually store. + */ + +/** Lifecycle states an incident moves through. */ +export type IncidentStatus = + | 'new' + | 'open' + | 'acknowledged' + | 'investigating' + | 'contained' + | 'resolved' + | 'closed' + | 'reopened'; + +/** Response priority. P1 is the most urgent. */ +export type IncidentPriority = 'p1' | 'p2' | 'p3' | 'p4'; + +/** Impact rating, independent of how urgently it is being worked. */ +export type IncidentSeverity = 'low' | 'medium' | 'high' | 'critical'; + +export interface Incident { + id: string; + title: string; + description: string; + status: IncidentStatus; + severity: IncidentSeverity; + priority: IncidentPriority; + category?: string; + assignedTo?: string; + detectionSource?: string; + tags: string[]; + createdAt: string; + updatedAt: string; +} + +/** Every status, in lifecycle order, for rendering filters deterministically. */ +export const INCIDENT_STATUSES: IncidentStatus[] = [ + 'new', + 'open', + 'acknowledged', + 'investigating', + 'contained', + 'resolved', + 'closed', + 'reopened', +]; + +/** Every priority, most urgent first. */ +export const INCIDENT_PRIORITIES: IncidentPriority[] = ['p1', 'p2', 'p3', 'p4']; + +/** Human-readable labels for priorities, shown alongside the indicator dot. */ +export const PRIORITY_LABELS: Record = { + p1: 'P1 — Critical', + p2: 'P2 — High', + p3: 'P3 — Normal', + p4: 'P4 — Low', +}; + +/** Statuses that mean the incident no longer needs active work. */ +export const TERMINAL_STATUSES: IncidentStatus[] = ['resolved', 'closed']; + +export const isTerminal = (status: IncidentStatus): boolean => TERMINAL_STATUSES.includes(status); + +export const MOCK_INCIDENTS: Incident[] = [ + { + id: 'inc-1001', + title: 'Unverified proxy upgrade on lending pool', + description: + 'Proxy admin executed an implementation swap with no timelock delay. Funds remain in the pool pending review.', + status: 'investigating', + severity: 'critical', + priority: 'p1', + category: 'Smart Contract', + assignedTo: 'A. Okafor', + detectionSource: 'contract-monitor', + tags: ['proxy', 'upgrade', 'ethereum'], + createdAt: '2026-08-18T09:12:00.000Z', + updatedAt: '2026-08-19T07:45:00.000Z', + }, + { + id: 'inc-1002', + title: 'Bridge withdrawal spike from a single address', + description: + 'One address accounted for 62% of bridge withdrawals in a ten minute window, well outside its historical pattern.', + status: 'acknowledged', + severity: 'high', + priority: 'p2', + category: 'Bridge', + assignedTo: 'M. Adeyemi', + detectionSource: 'anomaly-engine', + tags: ['bridge', 'exfiltration'], + createdAt: '2026-08-18T14:03:00.000Z', + updatedAt: '2026-08-19T06:20:00.000Z', + }, + { + id: 'inc-1003', + title: 'Oracle price deviation beyond tolerance', + description: + 'Reported price diverged from the aggregate feed by 8.4% for three consecutive blocks.', + status: 'contained', + severity: 'high', + priority: 'p2', + category: 'Oracle', + assignedTo: 'A. Okafor', + detectionSource: 'price-feed-monitor', + tags: ['oracle', 'deviation'], + createdAt: '2026-08-17T22:47:00.000Z', + updatedAt: '2026-08-18T11:10:00.000Z', + }, + { + id: 'inc-1004', + title: 'Repeated failed admin authentication', + description: + 'Fourteen failed sign-ins against an operator account from three regions inside an hour.', + status: 'new', + severity: 'medium', + priority: 'p3', + category: 'Access Control', + detectionSource: 'auth-service', + tags: ['auth', 'brute-force'], + createdAt: '2026-08-19T05:31:00.000Z', + updatedAt: '2026-08-19T05:31:00.000Z', + }, + { + id: 'inc-1005', + title: 'Anomalous gas usage on settlement contract', + description: + 'Settlement calls consumed roughly four times their usual gas. No loss of funds identified.', + status: 'open', + severity: 'low', + priority: 'p4', + category: 'Protocol Health', + detectionSource: 'protocol-health', + tags: ['gas', 'performance'], + createdAt: '2026-08-16T18:22:00.000Z', + updatedAt: '2026-08-17T09:05:00.000Z', + }, + { + id: 'inc-1006', + title: 'Flash loan probing against vault', + description: + 'Sequence of flash loans testing vault collateral limits. No position became liquidatable.', + status: 'resolved', + severity: 'medium', + priority: 'p3', + category: 'Smart Contract', + assignedTo: 'M. Adeyemi', + detectionSource: 'contract-monitor', + tags: ['flash-loan', 'vault'], + createdAt: '2026-08-14T11:58:00.000Z', + updatedAt: '2026-08-15T16:40:00.000Z', + }, + { + id: 'inc-1007', + title: 'Sanctioned address interaction', + description: 'An address on the sanctions watchlist received funds from a monitored contract.', + status: 'reopened', + severity: 'critical', + priority: 'p1', + category: 'Compliance', + assignedTo: 'A. Okafor', + detectionSource: 'watchlist', + tags: ['compliance', 'sanctions'], + createdAt: '2026-08-12T08:15:00.000Z', + updatedAt: '2026-08-19T04:02:00.000Z', + }, + { + id: 'inc-1008', + title: 'Duplicate webhook deliveries from indexer', + description: + 'Indexer replayed a block range, producing duplicate alerts. Deduplication has been confirmed working.', + status: 'closed', + severity: 'low', + priority: 'p4', + category: 'Infrastructure', + detectionSource: 'indexer', + tags: ['webhook', 'duplicates'], + createdAt: '2026-08-10T13:44:00.000Z', + updatedAt: '2026-08-11T10:12:00.000Z', + }, +];