diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index a14f1ef..ba6be4c 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -527,3 +527,98 @@ def test_webhook(db: Annotated[Session, Depends(get_db)]) -> _TestWebhookRespons }, ) return _TestWebhookResponse(success=True, message=f"Test notification sent to {url}") + + +# ── /api/changes ────────────────────────────────────────────────────────────── + + +class ScanEventOut(BaseModel): + id: int + scan_id: int + device_id: int | None + event_type: str + detail: str | None + occurred_at: str | None + reviewed: bool + + +class ChangesSummaryOut(BaseModel): + unreviewed: int + + +@router.get("/changes", response_model=list[ScanEventOut]) +def list_changes( + db: Annotated[Session, Depends(get_db)], + reviewed: bool | None = None, + device_id: int | None = None, + limit: int = 200, +) -> list[ScanEventOut]: + """Return scan change events, newest first.""" + from app.models.scan_event import ScanEvent + + stmt = select(ScanEvent).order_by(ScanEvent.occurred_at.desc()).limit(limit) + if reviewed is not None: + stmt = stmt.where(ScanEvent.reviewed == reviewed) + if device_id is not None: + stmt = stmt.where(ScanEvent.device_id == device_id) + events = db.execute(stmt).scalars().all() + return [_event_to_out(e) for e in events] + + +@router.get("/changes/summary", response_model=ChangesSummaryOut) +def changes_summary(db: Annotated[Session, Depends(get_db)]) -> ChangesSummaryOut: + """Return count of unreviewed change events.""" + from sqlalchemy import func as sqlfunc + + from app.models.scan_event import ScanEvent + + count = db.execute( + select(sqlfunc.count()).select_from(ScanEvent).where(ScanEvent.reviewed == False) # noqa: E712 — SQLAlchemy needs == False + ).scalar_one() + return ChangesSummaryOut(unreviewed=count or 0) + + +@router.patch("/changes/{event_id}/reviewed", response_model=ScanEventOut) +def mark_event_reviewed( + event_id: int, + db: Annotated[Session, Depends(get_db)], +) -> ScanEventOut: + """Mark a single change event as reviewed.""" + from app.models.scan_event import ScanEvent + + event = db.execute(select(ScanEvent).where(ScanEvent.id == event_id)).scalar_one_or_none() + if event is None: + raise HTTPException(status_code=404, detail="Event not found") + event.reviewed = True + db.commit() + db.refresh(event) + return _event_to_out(event) + + +@router.patch("/changes/reviewed/all", response_model=ChangesSummaryOut) +def mark_all_reviewed(db: Annotated[Session, Depends(get_db)]) -> ChangesSummaryOut: + """Mark all unreviewed change events as reviewed.""" + from app.models.scan_event import ScanEvent + + db.execute( + select(ScanEvent).where(ScanEvent.reviewed == False) # noqa: E712 — SQLAlchemy needs == False + ) + from sqlalchemy import update as sa_update + + db.execute( + sa_update(ScanEvent).where(ScanEvent.reviewed == False).values(reviewed=True) # noqa: E712 — SQLAlchemy needs == False + ) + db.commit() + return ChangesSummaryOut(unreviewed=0) + + +def _event_to_out(e) -> ScanEventOut: # noqa: ANN001 — SQLAlchemy instance + return ScanEventOut( + id=e.id, + scan_id=e.scan_id, + device_id=e.device_id, + event_type=e.event_type, + detail=e.detail, + occurred_at=e.occurred_at.isoformat() if e.occurred_at else None, + reviewed=bool(e.reviewed), + ) diff --git a/backend/app/db.py b/backend/app/db.py index dde7c65..427fd3a 100644 --- a/backend/app/db.py +++ b/backend/app/db.py @@ -61,6 +61,9 @@ def init_db() -> None: ) from app.models import risk as _risk # noqa: F401 — side-effect import registers ORM tables from app.models import scan as _scan # noqa: F401 — side-effect import registers ORM tables + from app.models import ( + scan_event as _scan_event, # noqa: F401 — side-effect import registers ORM tables + ) from app.models import ( settings as _settings, # noqa: F401 — side-effect import registers ORM tables ) diff --git a/backend/app/models/scan_event.py b/backend/app/models/scan_event.py new file mode 100644 index 0000000..519b8fb --- /dev/null +++ b/backend/app/models/scan_event.py @@ -0,0 +1,28 @@ +"""ScanEvent ORM model — one row per notable change detected during a scan.""" + +from sqlalchemy import Boolean, Column, DateTime, ForeignKey, Integer, String, func + +from app.db import Base + + +class ScanEvent(Base): + """A change event detected by comparing two consecutive scan snapshots. + + event_type values: + device_appeared — a new IP was found on the network + device_disappeared — a previously-seen IP was not found in this scan + port_opened — a port/protocol pair that was not open is now open + port_closed — a port/protocol pair that was open is no longer open + risk_appeared — a new misconfiguration risk was detected + risk_resolved — a previously-detected risk is no longer present + """ + + __tablename__ = "scan_events" + + id = Column(Integer, primary_key=True, index=True) + scan_id = Column(Integer, ForeignKey("scans.id"), nullable=False, index=True) + device_id = Column(Integer, ForeignKey("devices.id"), nullable=True, index=True) + event_type = Column(String, nullable=False) + detail = Column(String, nullable=True) # JSON string with event-specific context + occurred_at = Column(DateTime, default=func.now()) + reviewed = Column(Boolean, nullable=False, default=False, server_default="0") diff --git a/backend/app/scan_runner.py b/backend/app/scan_runner.py index 4a3b718..492fdbf 100644 --- a/backend/app/scan_runner.py +++ b/backend/app/scan_runner.py @@ -6,8 +6,10 @@ from __future__ import annotations +import json import logging import time +from dataclasses import dataclass, field from datetime import UTC, datetime from sqlalchemy.orm import Session @@ -18,6 +20,16 @@ logger = logging.getLogger(__name__) +@dataclass +class _PersistSummary: + """Summary of what changed during a single call to _persist_result.""" + + new_device_ids: list[int] = field(default_factory=list) + disappeared_ips: list[str] = field(default_factory=list) + # keys: device_id, event_type, port_number, protocol, service_name + port_events: list[dict] = field(default_factory=list) + + def run_scan_and_persist(triggered_by: str = "scheduler") -> int: """Run a full scan cycle and persist results. @@ -41,26 +53,41 @@ def run_scan_and_persist(triggered_by: str = "scheduler") -> int: db.commit() result: ScanResult = orchestrate_scan() - new_device_ids = _persist_result(db, result) + summary = _persist_result(db, result) + devices_found = len(result.hosts) + len(result.arp_only) scan.current_stage = "analysing" db.commit() # Run misconfiguration checks against all discovered devices + from sqlalchemy import select as sa_select + from app.analysis import ( run_all_checks, # noqa: PLC0415 — deferred to avoid circular import at module level ) + from app.models.risk import ( + Risk, # noqa: PLC0415 — deferred to avoid circular import at module level + ) + + # Snapshot existing risks (before checks run) so we can detect changes + pre_risks = { + (r.device_id, r.check_id): (r.title, r.severity) + for r in db.execute(sa_select(Risk)).scalars().all() + } run_all_checks(db) + post_risks = { + (r.device_id, r.check_id): (r.title, r.severity) + for r in db.execute(sa_select(Risk)).scalars().all() + } + + # Record all change events for this scan + _record_scan_events(db, scan_id, summary, pre_risks, post_risks) + # Snapshot risk counts for trend tracking from sqlalchemy import func as sqlfunc - from sqlalchemy import select as sa_select - - from app.models.risk import ( - Risk, # noqa: PLC0415 — deferred to avoid circular import at module level - ) risk_counts: dict[str, int] = {"critical": 0, "high": 0, "medium": 0, "low": 0} for sev in risk_counts: @@ -86,7 +113,7 @@ def run_scan_and_persist(triggered_by: str = "scheduler") -> int: notify_scan_complete( db, scan_id=scan_id, - new_device_ids=new_device_ids, + new_device_ids=summary.new_device_ids, risk_counts=risk_counts, ) @@ -117,22 +144,30 @@ def run_scan_and_persist(triggered_by: str = "scheduler") -> int: return scan_id -def _persist_result(db: Session, result: ScanResult) -> list[int]: +def _persist_result(db: Session, result: ScanResult) -> _PersistSummary: """Upsert all devices and ports from a ScanResult into the database. - Returns a list of device IDs that were newly created in this scan. + Returns a _PersistSummary with new device IDs, disappeared IPs, and port events. """ from sqlalchemy import select from app.models.device import Device as DeviceModel from app.models.device import Port - # Snapshot existing IPs before upserting so we can detect brand-new devices + # Snapshot existing state before upserting existing_ips: set[str] = {row[0] for row in db.execute(select(DeviceModel.ip_address)).all()} - new_device_ids: list[int] = [] + + # Snapshot existing ports per device for change detection + pre_ports: dict[int, set[tuple[int, str]]] = {} + for device_row in db.execute(select(DeviceModel)).scalars().all(): + pre_ports[device_row.id] = {(p.port_number, p.protocol) for p in device_row.ports} + + summary = _PersistSummary() + scanned_ips: set[str] = set() # Full nmap results for nh in result.hosts: + scanned_ips.add(nh.ip) device = upsert_device( db, ip_address=nh.ip, @@ -142,9 +177,11 @@ def _persist_result(db: Session, result: ScanResult) -> list[int]: ) db.flush() if nh.ip not in existing_ips: - new_device_ids.append(device.id) + summary.new_device_ids.append(device.id) current_ports = {(p.port_number, p.protocol) for p in nh.ports} + old_ports = pre_ports.get(device.id, set()) + for port in nh.ports: upsert_port( db, @@ -155,6 +192,32 @@ def _persist_result(db: Session, result: ScanResult) -> list[int]: version_banner=port.version_banner or None, ) + # Detect newly opened ports (only for existing devices) + if nh.ip in existing_ips: + for port_key in current_ports - old_ports: + port_obj = next( + (p for p in nh.ports if (p.port_number, p.protocol) == port_key), None + ) + summary.port_events.append( + { + "device_id": device.id, + "event_type": "port_opened", + "port_number": port_key[0], + "protocol": port_key[1], + "service_name": port_obj.service_name if port_obj else None, + } + ) + for port_key in old_ports - current_ports: + summary.port_events.append( + { + "device_id": device.id, + "event_type": "port_closed", + "port_number": port_key[0], + "protocol": port_key[1], + "service_name": None, + } + ) + # Remove ports no longer seen in this scan existing_ports = db.execute(select(Port).where(Port.device_id == device.id)).scalars().all() for existing in existing_ports: @@ -163,6 +226,7 @@ def _persist_result(db: Session, result: ScanResult) -> list[int]: # ARP-only hosts (no nmap data) for ah in result.arp_only: + scanned_ips.add(ah.ip) device = upsert_device( db, ip_address=ah.ip, @@ -171,7 +235,132 @@ def _persist_result(db: Session, result: ScanResult) -> list[int]: ) db.flush() if ah.ip not in existing_ips: - new_device_ids.append(device.id) + summary.new_device_ids.append(device.id) + + # Detect disappeared devices (were in DB, not found in this scan) + summary.disappeared_ips = list(existing_ips - scanned_ips) db.commit() - return new_device_ids + return summary + + +def _record_scan_events( + db: Session, + scan_id: int, + summary: _PersistSummary, + pre_risks: dict[tuple[int, str], tuple[str, str]], + post_risks: dict[tuple[int, str], tuple[str, str]], +) -> None: + """Insert ScanEvent rows for all detected changes.""" + from sqlalchemy import select + + from app.models.device import Device as DeviceModel + from app.models.scan_event import ScanEvent + + now = datetime.now(tz=UTC) + events: list[ScanEvent] = [] + + # device_appeared + if summary.new_device_ids: + devices = ( + db.execute(select(DeviceModel).where(DeviceModel.id.in_(summary.new_device_ids))) + .scalars() + .all() + ) + for d in devices: + events.append( + ScanEvent( + scan_id=scan_id, + device_id=d.id, + event_type="device_appeared", + detail=json.dumps( + { + "ip": d.ip_address, + "hostname": d.hostname, + "mac": d.mac_address, + "vendor": d.vendor, + } + ), + occurred_at=now, + ) + ) + + # device_disappeared + if summary.disappeared_ips: + devices = ( + db.execute( + select(DeviceModel).where(DeviceModel.ip_address.in_(summary.disappeared_ips)) + ) + .scalars() + .all() + ) + for d in devices: + events.append( + ScanEvent( + scan_id=scan_id, + device_id=d.id, + event_type="device_disappeared", + detail=json.dumps( + { + "ip": d.ip_address, + "hostname": d.hostname, + "label": d.label, + "last_seen": d.last_seen.isoformat() if d.last_seen else None, + } + ), + occurred_at=now, + ) + ) + + # port_opened / port_closed + for pe in summary.port_events: + events.append( + ScanEvent( + scan_id=scan_id, + device_id=pe["device_id"], + event_type=pe["event_type"], + detail=json.dumps( + { + "port": pe["port_number"], + "protocol": pe["protocol"], + "service": pe["service_name"], + } + ), + occurred_at=now, + ) + ) + + # risk_appeared / risk_resolved + appeared_keys = set(post_risks) - set(pre_risks) + resolved_keys = set(pre_risks) - set(post_risks) + + for key in appeared_keys: + device_id, check_id = key + title, severity = post_risks[key] + events.append( + ScanEvent( + scan_id=scan_id, + device_id=device_id, + event_type="risk_appeared", + detail=json.dumps({"check_id": check_id, "title": title, "severity": severity}), + occurred_at=now, + ) + ) + + for key in resolved_keys: + device_id, check_id = key + title, severity = pre_risks[key] + events.append( + ScanEvent( + scan_id=scan_id, + device_id=device_id, + event_type="risk_resolved", + detail=json.dumps({"check_id": check_id, "title": title, "severity": severity}), + occurred_at=now, + ) + ) + + if events: + db.add_all(events) + db.commit() + logger.info("Scan %d: recorded %d change events", scan_id, len(events)) diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index e565273..0b875ae 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -7,6 +7,7 @@ import { useState } from "react"; import { NavLink, Outlet } from "react-router-dom"; import { useTheme } from "../hooks"; import { useAppVersion } from "../hooks/useAppVersion"; +import { useChangesSummary } from "../hooks/useChanges"; /** Copy text to clipboard; works on HTTP as well as HTTPS. */ function copyViaExecCommand(text: string): void { @@ -24,6 +25,7 @@ const navLinks = [ { to: "/", label: "Dashboard", end: true }, { to: "/devices", label: "Devices", end: false }, { to: "/risks", label: "Risks", end: false }, + { to: "/changes", label: "Changes", end: false }, { to: "/history", label: "History", end: false }, { to: "/recommendations", label: "Recommendations", end: false }, { to: "/docs", label: "Docs", end: false }, @@ -101,6 +103,7 @@ function NetworkIcon() { export function Layout() { const { theme, toggleTheme } = useTheme(); const { version } = useAppVersion(); + const { unreviewed } = useChangesSummary(); const [copied, setCopied] = useState(false); const handleCopyVersion = () => { @@ -157,7 +160,7 @@ export function Layout() { end={end} className={({ isActive }) => [ - "rounded-md px-3 py-1.5 text-sm font-medium transition-colors duration-150", + "relative rounded-md px-3 py-1.5 text-sm font-medium transition-colors duration-150", isActive ? "bg-[var(--color-accent-primary)]/10 text-[var(--color-accent-primary)]" : "text-[var(--color-text-secondary)] hover:bg-[var(--color-border)]/40 hover:text-[var(--color-text-primary)]", @@ -165,6 +168,11 @@ export function Layout() { } > {label} + {label === "Changes" && unreviewed > 0 && ( + + {unreviewed > 99 ? "99+" : unreviewed} + + )} ))} diff --git a/frontend/src/hooks/index.ts b/frontend/src/hooks/index.ts index ca563f6..d63d153 100644 --- a/frontend/src/hooks/index.ts +++ b/frontend/src/hooks/index.ts @@ -32,3 +32,10 @@ export { useToast } from "./useToast"; export { useAppVersion } from "./useAppVersion"; export type { UseAppVersionResult } from "./useAppVersion"; + +export { useChanges, useChangesSummary } from "./useChanges"; +export type { + UseChangesResult, + UseChangesOptions, + UseChangesSummaryResult, +} from "./useChanges"; diff --git a/frontend/src/hooks/useChanges.ts b/frontend/src/hooks/useChanges.ts new file mode 100644 index 0000000..54f48ac --- /dev/null +++ b/frontend/src/hooks/useChanges.ts @@ -0,0 +1,88 @@ +import { useCallback, useEffect, useState } from "react"; +import type { ChangesSummary, ScanEvent } from "../types/api"; + +export interface UseChangesOptions { + reviewed?: boolean; + device_id?: number; + limit?: number; +} + +export interface UseChangesResult { + events: ScanEvent[]; + loading: boolean; + error: string | null; + refetch: () => void; + markReviewed: (id: number) => Promise; + markAllReviewed: () => Promise; +} + +export function useChanges(opts: UseChangesOptions = {}): UseChangesResult { + const { reviewed, device_id, limit = 200 } = opts; + const [events, setEvents] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const fetchEvents = useCallback(() => { + const params = new URLSearchParams(); + if (reviewed !== undefined) params.set("reviewed", String(reviewed)); + if (device_id !== undefined) params.set("device_id", String(device_id)); + params.set("limit", String(limit)); + + setLoading(true); + fetch(`/api/changes?${params}`) + .then((r) => { + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return r.json() as Promise; + }) + .then(setEvents) + .catch((e: Error) => setError(e.message)) + .finally(() => setLoading(false)); + }, [reviewed, device_id, limit]); + + useEffect(() => { + fetchEvents(); + }, [fetchEvents]); + + const markReviewed = async (id: number) => { + await fetch(`/api/changes/${id}/reviewed`, { method: "PATCH" }); + setEvents((prev) => + prev.map((e) => (e.id === id ? { ...e, reviewed: true } : e)), + ); + }; + + const markAllReviewed = async () => { + await fetch("/api/changes/reviewed/all", { method: "PATCH" }); + setEvents((prev) => prev.map((e) => ({ ...e, reviewed: true }))); + }; + + return { + events, + loading, + error, + refetch: fetchEvents, + markReviewed, + markAllReviewed, + }; +} + +export interface UseChangesSummaryResult { + unreviewed: number; +} + +export function useChangesSummary(): UseChangesSummaryResult { + const [unreviewed, setUnreviewed] = useState(0); + + useEffect(() => { + const fetchSummary = () => { + fetch("/api/changes/summary") + .then((r) => r.json() as Promise) + .then((d) => setUnreviewed(d.unreviewed)) + .catch(() => {}); + }; + fetchSummary(); + const interval = setInterval(fetchSummary, 30_000); + return () => clearInterval(interval); + }, []); + + return { unreviewed }; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index be094d4..46ad787 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -8,6 +8,7 @@ import { DevicesPage, DeviceDetailPage, DocsPage, + ChangesPage, HistoryPage, RisksPage, RecommendationsPage, @@ -32,6 +33,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render( /> } /> } /> + } /> } /> diff --git a/frontend/src/pages/ChangesPage.tsx b/frontend/src/pages/ChangesPage.tsx new file mode 100644 index 0000000..25583f7 --- /dev/null +++ b/frontend/src/pages/ChangesPage.tsx @@ -0,0 +1,233 @@ +/** + * ChangesPage — timeline of network changes detected between scans. + * Route: /changes + */ +import { useState } from "react"; +import { Link } from "react-router-dom"; +import { Card, Badge, PageHeader } from "../components"; +import { useChanges } from "../hooks/useChanges"; +import type { ScanEvent } from "../types/api"; + +const EVENT_META: Record< + ScanEvent["event_type"], + { + label: string; + icon: string; + variant: "critical" | "high" | "medium" | "neutral" | "positive"; + } +> = { + device_appeared: { label: "New device", icon: "📡", variant: "medium" }, + device_disappeared: { + label: "Device offline", + icon: "🔌", + variant: "neutral", + }, + port_opened: { label: "Port opened", icon: "🔓", variant: "high" }, + port_closed: { label: "Port closed", icon: "🔒", variant: "neutral" }, + risk_appeared: { label: "New risk", icon: "⚠️", variant: "critical" }, + risk_resolved: { label: "Risk resolved", icon: "✅", variant: "positive" }, +}; + +function parseDetail(raw: string | null): Record { + if (!raw) return {}; + try { + return JSON.parse(raw) as Record; + } catch { + return {}; + } +} + +function EventDetail({ event }: { event: ScanEvent }) { + const d = parseDetail(event.detail); + switch (event.event_type) { + case "device_appeared": + case "device_disappeared": + return ( + + {String(d.ip ?? "")} + {d.hostname ? <> · {String(d.hostname)} : null} + {d.vendor ? <> · {String(d.vendor)} : null} + {d.label ? <> · "{String(d.label)}" : null} + + ); + case "port_opened": + case "port_closed": + return ( + + + {String(d.port ?? "")}/{String(d.protocol ?? "")} + + {d.service ? <> · {String(d.service)} : null} + + ); + case "risk_appeared": + case "risk_resolved": + return ( + + {String(d.title ?? "")} + {d.severity ? ( + <> + {" "} + · {String(d.severity)} + + ) : null} + + ); + default: + return {event.detail ?? ""}; + } +} + +function EventRow({ + event, + onDismiss, +}: { + event: ScanEvent; + onDismiss: (id: number) => void; +}) { + const meta = EVENT_META[event.event_type] ?? { + label: event.event_type, + icon: "•", + variant: "neutral" as const, + }; + + return ( +
+ +
+
+ + {meta.label} + + {event.device_id && ( + + device #{event.device_id} + + )} +
+

+ +

+
+
+ + {!event.reviewed && ( + + )} +
+
+ ); +} + +export function ChangesPage() { + const [showReviewed, setShowReviewed] = useState(false); + const { events, loading, markReviewed, markAllReviewed } = useChanges( + showReviewed ? {} : { reviewed: false }, + ); + + const unreviewedCount = events.filter((e) => !e.reviewed).length; + + // Group events by scan_id + const grouped = events.reduce>((acc, e) => { + const list = acc.get(e.scan_id) ?? []; + list.push(e); + acc.set(e.scan_id, list); + return acc; + }, new Map()); + + return ( +
+ + + {unreviewedCount > 0 && ( + + )} +
+ } + /> + + {loading ? ( + +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ + ) : events.length === 0 ? ( + +

+ {showReviewed + ? "No change events recorded yet." + : "No unreviewed changes — you're up to date."} +

+
+ ) : ( +
+ {Array.from(grouped.entries()).map(([scanId, scanEvents]) => ( + +
+ + Scan #{scanId} + + + {scanEvents.length} event{scanEvents.length !== 1 ? "s" : ""} + +
+ {scanEvents.map((e) => ( + void markReviewed(id)} + /> + ))} +
+ ))} +
+ )} +
+ ); +} diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts index 966f512..f27ffde 100644 --- a/frontend/src/pages/index.ts +++ b/frontend/src/pages/index.ts @@ -8,3 +8,4 @@ export { RisksPage } from "./RisksPage"; export { RecommendationsPage } from "./RecommendationsPage"; export { RecommendationDetailPage } from "./RecommendationDetailPage"; export { SettingsPage } from "./SettingsPage"; +export { ChangesPage } from "./ChangesPage"; diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index 87a4cb1..b5d520f 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -93,3 +93,23 @@ export interface HealthResponse { status: string; version: string; } + +export interface ScanEvent { + id: number; + scan_id: number; + device_id: number | null; + event_type: + | "device_appeared" + | "device_disappeared" + | "port_opened" + | "port_closed" + | "risk_appeared" + | "risk_resolved"; + detail: string | null; // JSON string + occurred_at: string | null; + reviewed: boolean; +} + +export interface ChangesSummary { + unreviewed: number; +}