From ce596e3ba1902189e129689b0090d0498e57e09c Mon Sep 17 00:00:00 2001 From: thatcodebabe Date: Wed, 19 Aug 2026 17:22:11 +0100 Subject: [PATCH] feat(web): add incident dashboard workspace Adds `apps/web/app/incidents/`, a workspace for reviewing active security investigations: an incident listing, status and priority filters, and priority indicators. The `Incident` type mirrors the string columns on the existing `Incident` Prisma model rather than inventing a parallel shape, so the dashboard cannot drift from what the database can store. Statuses, priorities and severities are exhaustive unions over those documented values. Behaviour notes: - Filter counts are computed from the unfiltered incident set, so each control shows how many incidents it would reveal rather than how many are currently on screen. A filter matching nothing is disabled rather than hidden, keeping the control set stable as data changes. - Status and priority filters compose; the clear control appears only once a filter is applied. - Priority is never conveyed by colour alone. The indicator dot is `aria-hidden` and the literal priority text is always rendered, so the most important signal on the page survives for anyone who cannot separate the hues. - The summary list carries `role="group"`. A bare `
` exposes no ARIA role, which would cause its `aria-label` to be dropped by assistive technology. - Incidents are passed as a prop defaulting to the mock set, matching how the other workspaces in `apps/web/app` source data, so wiring this to the real API later needs no change to the view. 15 tests cover the listing, both filters and their combination, the pressed state, the result count, clearing, the disabled-filter rule, the empty state and the summary counts. Full dashboard suite: 56 passing across 6 suites. Lint and prettier clean. --- apps/web/app/incidents/IncidentFilters.tsx | 103 +++++++ apps/web/app/incidents/IncidentList.tsx | 100 +++++++ apps/web/app/incidents/incidents.css | 302 +++++++++++++++++++++ apps/web/app/incidents/page.spec.tsx | 167 ++++++++++++ apps/web/app/incidents/page.tsx | 112 ++++++++ apps/web/app/incidents/types.ts | 186 +++++++++++++ 6 files changed, 970 insertions(+) create mode 100644 apps/web/app/incidents/IncidentFilters.tsx create mode 100644 apps/web/app/incidents/IncidentList.tsx create mode 100644 apps/web/app/incidents/incidents.css create mode 100644 apps/web/app/incidents/page.spec.tsx create mode 100644 apps/web/app/incidents/page.tsx create mode 100644 apps/web/app/incidents/types.ts 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 ( +
    + {incidents.map(incident => ( +
  • +
    +
    + +
    +
    +

    {incident.title}

    + {incident.status} + + {incident.severity} + +
    + +

    {incident.description}

    + +
    +
    +
    ID
    +
    {incident.id}
    +
    + {incident.category && ( +
    +
    Category
    +
    {incident.category}
    +
    + )} +
    +
    Owner
    +
    {incident.assignedTo ?? 'Unassigned'}
    +
    + {incident.detectionSource && ( +
    +
    Source
    +
    {incident.detectionSource}
    +
    + )} +
    +
    Updated
    +
    {formatTimestamp(incident.updatedAt)}
    +
    +
    + + {incident.tags.length > 0 && ( +
      + {incident.tags.map(tag => ( +
    • + {tag} +
    • + ))} +
    + )} +
    +
  • + ))} +
+ ); +}; + +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', + }, +];