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
4 changes: 2 additions & 2 deletions src/app/(dashboard)/reports/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,12 @@ export default async function ReportsPage({ searchParams }: { searchParams: Prom

const sections: ReportSectionEntry[] = [
{ id: "findings", title: "Key Findings", defaultSpan: 12, content: <KeyFindingsSection findings={data.findings} /> },
{ id: "exposure", title: "Exposure", defaultSpan: 12, content: <ExposureSection data={data.exposure} /> },
{ id: "exposure", title: "Exposure", defaultSpan: 12, content: <ExposureSection data={data.exposure} deltas={data.deltas} /> },
{ id: "datatypes", title: "Data Types", defaultSpan: 6, content: <DataTypeSection rows={data.dataTypes} /> },
{ id: "departments", title: "Departments", defaultSpan: 6, content: <DepartmentSection rows={data.departments} /> },
{ id: "trends", title: "Trends", defaultSpan: 12, content: <TrendsSection data={data.trends} /> },
{ id: "employees", title: "Employees", defaultSpan: 12, content: <EmployeeSection rows={data.employees} /> },
{ id: "compliance", title: "Compliance", defaultSpan: 12, content: <ComplianceSection data={data.compliance} /> },
{ id: "compliance", title: "Compliance", defaultSpan: 12, content: <ComplianceSection data={data.compliance} deltas={data.deltas} /> },
]

return (
Expand Down
33 changes: 32 additions & 1 deletion src/components/dashboard/StatCard.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
import { cn } from "@/lib/utils"
import type { LucideIcon } from "lucide-react"
import { ArrowUp, ArrowDown, Minus, type LucideIcon } from "lucide-react"

export type StatDelta = {
value: number
label?: string
// Which direction is "good" (green). Security metrics usually want "down".
goodWhen?: "up" | "down"
}

interface StatCardProps {
label: string
value: string | number
description?: string
icon: LucideIcon
variant?: "default" | "critical" | "high" | "medium" | "ok"
delta?: StatDelta
}

const variants = {
Expand Down Expand Up @@ -43,6 +51,7 @@ export function StatCard({
description,
icon: Icon,
variant = "default",
delta,
}: StatCardProps) {
const v = variants[variant]

Expand All @@ -57,6 +66,7 @@ export function StatCard({
{description && (
<p className="text-xs text-muted-foreground">{description}</p>
)}
{delta && <DeltaPill delta={delta} />}
</div>
<div className={cn("flex size-9 shrink-0 items-center justify-center rounded-lg", v.iconBg)}>
<Icon className={cn("size-5", v.icon)} />
Expand All @@ -65,3 +75,24 @@ export function StatCard({
</div>
)
}

function DeltaPill({ delta }: { delta: StatDelta }) {
const { value, label, goodWhen = "up" } = delta
const Icon = value > 0 ? ArrowUp : value < 0 ? ArrowDown : Minus
const direction = value > 0 ? "up" : value < 0 ? "down" : "flat"
const tone =
direction === "flat"
? "text-muted-foreground"
: direction === goodWhen
? "text-severity-ok"
: "text-severity-high"

return (
<p className={cn("flex items-center gap-1 text-xs font-medium tabular-nums", tone)}>
<Icon className="size-3" />
{value > 0 ? "+" : ""}
{value}
{label && <span className="font-normal text-muted-foreground">{label}</span>}
</p>
)
}
12 changes: 9 additions & 3 deletions src/components/reports/ComplianceSection.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { ShieldCheck, BellRing, CheckCircle2, AlertTriangle } from "lucide-react"
import { StatCard } from "@/components/dashboard/StatCard"
import { ReportSection } from "./ReportSection"
import type { ComplianceSummary } from "@/lib/reports/types"
import type { ComplianceSummary, ReportDeltas } from "@/lib/reports/types"

export function ComplianceSection({ data }: { data: ComplianceSummary }) {
export function ComplianceSection({ data, deltas }: { data: ComplianceSummary; deltas: ReportDeltas }) {
return (
<ReportSection
title="Compliance and audit"
Expand All @@ -16,7 +16,13 @@ export function ComplianceSection({ data }: { data: ComplianceSummary }) {
description={`${data.exposedEmployees} exposed`}
icon={ShieldCheck}
/>
<StatCard label="Open alerts" value={data.alertsOpen} icon={BellRing} variant="high" />
<StatCard
label="Open alerts"
value={data.alertsOpen}
icon={BellRing}
variant="high"
delta={{ value: deltas.newAlerts.current, label: `new ${deltas.windowLabel}`, goodWhen: "down" }}
/>
<StatCard
label="Resolved"
value={data.alertsResolved}
Expand Down
12 changes: 9 additions & 3 deletions src/components/reports/ExposureSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@ import { Users, ShieldAlert, Database, Activity } from "lucide-react"
import { StatCard } from "@/components/dashboard/StatCard"
import { getRiskLevel } from "@/lib/risk"
import { ReportSection } from "./ReportSection"
import type { ExposureSummary } from "@/lib/reports/types"
import type { ExposureSummary, ReportDeltas } from "@/lib/reports/types"

export function ExposureSection({ data }: { data: ExposureSummary }) {
export function ExposureSection({ data, deltas }: { data: ExposureSummary; deltas: ReportDeltas }) {
const riskVariant = getRiskLevel(data.riskScore).variant

return (
Expand All @@ -20,8 +20,14 @@ export function ExposureSection({ data }: { data: ExposureSummary }) {
description={`${data.exposureRate}% of workforce`}
icon={ShieldAlert}
variant="high"
delta={{ value: deltas.newlyExposed.current, label: deltas.windowLabel, goodWhen: "down" }}
/>
<StatCard
label="Breaches"
value={data.totalBreaches}
icon={Database}
delta={{ value: deltas.newBreaches.current, label: deltas.windowLabel, goodWhen: "down" }}
/>
<StatCard label="Breaches" value={data.totalBreaches} icon={Database} />
<StatCard
label="Risk score"
value={data.riskScore}
Expand Down
9 changes: 5 additions & 4 deletions src/components/reports/ReportCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import { Settings2, Check, GripVertical, Eye, EyeOff, RotateCcw } from "lucide-r
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"

const STORAGE_KEY = "datashield:reports:layout:v2"
const STORAGE_KEY = "datashield:reports:layout:v4"

export type Span = 4 | 6 | 12

Expand Down Expand Up @@ -68,7 +68,7 @@ function SortableSection({
style={{ transform: CSS.Transform.toString(transform), transition }}
className={cn(
SPAN_CLASS[span],
"relative",
"relative h-full",
isDragging && "opacity-0",
editing && "rounded-xl outline outline-2 outline-primary/30",
)}
Expand Down Expand Up @@ -260,7 +260,8 @@ export function ReportCanvas({ sections }: { sections: ReportSectionEntry[] }) {
)}
</div>

{/* Flow grid (screen) */}
{/* Flow grid (screen). Rows stretch and dense flow backfills holes, so 1/3 and
1/2 tiles fill empty space instead of leaving gaps. */}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
Expand All @@ -269,7 +270,7 @@ export function ReportCanvas({ sections }: { sections: ReportSectionEntry[] }) {
onDragCancel={() => setActiveId(null)}
>
<SortableContext items={visibleOrder} strategy={rectSortingStrategy}>
<div ref={gridRef} className="no-print grid grid-cols-12 items-start gap-4">
<div ref={gridRef} className="no-print grid grid-cols-12 gap-4 [grid-auto-flow:row_dense]">
{visibleOrder.map((id) => {
const section = byId.get(id)
if (!section) return null
Expand Down
2 changes: 1 addition & 1 deletion src/components/reports/ReportSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ interface ReportSectionProps {

export function ReportSection({ title, description, children }: ReportSectionProps) {
return (
<section className="rounded-xl border border-border bg-card p-5">
<section className="h-full rounded-xl border border-border bg-card p-5">
<div className="mb-4">
<h3 className="text-sm font-medium text-foreground">{title}</h3>
{description && <p className="text-xs text-muted-foreground">{description}</p>}
Expand Down
11 changes: 10 additions & 1 deletion src/lib/reports/csv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,16 @@ function exposureCsv(d: ReportData): string {
["breach", "source", "breach date", "affected employees"],
e.topBreaches.map((b) => [b.name, b.source, b.breachDate, b.affectedEmployees])
)
return `${summary}\n\nTop breaches\n${breaches}`
const dl = d.deltas
const deltas = toCsv(
["change", "current", "previous"],
[
["Newly exposed employees", dl.newlyExposed.current, dl.newlyExposed.previous],
["Breaches with new detections", dl.newBreaches.current, dl.newBreaches.previous],
["New alerts", dl.newAlerts.current, dl.newAlerts.previous],
]
)
return `${summary}\n\nTop breaches\n${breaches}\n\nPeriod change (${dl.windowLabel})\n${deltas}`
}

function dataTypesCsv(d: ReportData): string {
Expand Down
100 changes: 100 additions & 0 deletions src/lib/reports/deltas.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { prisma } from "@/lib/prisma"
import { employeeWhere, NO_DEPARTMENT, type ReportFilters } from "./filters"
import type { Delta, ReportDeltas } from "./types"

const DAY = 24 * 60 * 60 * 1000

type Window = { curStart: Date; curEnd: Date; prevStart: Date; prevEnd: Date; label: string }

// Comparison windows derived from the active filters. With an explicit date range
// we compare it against the equal-length window immediately before it; otherwise we
// compare the trailing 30 days against the 30 days before that.
function windows(f: ReportFilters): Window {
if (f.from && f.to) {
const curStart = new Date(`${f.from}T00:00:00.000`)
const curEnd = new Date(`${f.to}T23:59:59.999`)
const len = curEnd.getTime() - curStart.getTime()
return {
curStart,
curEnd,
prevStart: new Date(curStart.getTime() - len),
prevEnd: curStart,
label: "vs previous period",
}
}
const now = new Date()
const curStart = new Date(now.getTime() - 30 * DAY)
return {
curStart,
curEnd: now,
prevStart: new Date(now.getTime() - 60 * DAY),
prevEnd: curStart,
label: "in last 30 days",
}
}

function recordInWindow(f: ReportFilters, start: Date, end: Date) {
return {
detectedAt: { gte: start, lt: end },
...(f.dataType && { exposedData: { has: f.dataType } }),
}
}

function alertDept(f: ReportFilters) {
return f.department
? { employee: { department: f.department === NO_DEPARTMENT ? null : f.department } }
: {}
}

async function newlyExposed(companyId: string, f: ReportFilters, start: Date, end: Date): Promise<number> {
return prisma.employee.count({
where: {
...employeeWhere(companyId, f),
breachRecords: { some: recordInWindow(f, start, end) },
// First-time exposure: no breach record (of the same data-type scope) before the window.
NOT: {
breachRecords: {
some: { detectedAt: { lt: start }, ...(f.dataType && { exposedData: { has: f.dataType } }) },
},
},
},
})
}

function newBreaches(companyId: string, f: ReportFilters, start: Date, end: Date): Promise<number> {
return prisma.breach.count({
where: { records: { some: { employee: employeeWhere(companyId, f), ...recordInWindow(f, start, end) } } },
})
}

function newAlerts(companyId: string, f: ReportFilters, start: Date, end: Date): Promise<number> {
return prisma.alert.count({
where: { companyId, createdAt: { gte: start, lt: end }, ...alertDept(f) },
})
}

export async function getDeltas(companyId: string, f: ReportFilters): Promise<ReportDeltas> {
const w = windows(f)

const [
exposedCur, exposedPrev,
breachesCur, breachesPrev,
alertsCur, alertsPrev,
] = await Promise.all([
newlyExposed(companyId, f, w.curStart, w.curEnd),
newlyExposed(companyId, f, w.prevStart, w.prevEnd),
newBreaches(companyId, f, w.curStart, w.curEnd),
newBreaches(companyId, f, w.prevStart, w.prevEnd),
newAlerts(companyId, f, w.curStart, w.curEnd),
newAlerts(companyId, f, w.prevStart, w.prevEnd),
])

const delta = (current: number, previous: number): Delta => ({ current, previous })

return {
windowLabel: w.label,
newlyExposed: delta(exposedCur, exposedPrev),
newBreaches: delta(breachesCur, breachesPrev),
newAlerts: delta(alertsCur, alertsPrev),
}
}
30 changes: 29 additions & 1 deletion src/lib/reports/findings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type {
DataTypeExposure,
ExposureSummary,
Finding,
ReportDeltas,
} from "./types"

const ORDER: Record<Finding["severity"], number> = {
Expand Down Expand Up @@ -85,14 +86,41 @@ function dataFindings(types: DataTypeExposure[]): Finding[] {
return out
}

function trendWord(current: number, previous: number): string {
if (current > previous) return `up from ${previous}`
if (current < previous) return `down from ${previous}`
return `unchanged from ${previous}`
}

function deltaFindings(d: ReportDeltas): Finding[] {
const out: Finding[] = []
const { newlyExposed, newBreaches } = d

if (newlyExposed.current > 0)
out.push({
severity: newlyExposed.current > newlyExposed.previous ? "high" : "medium",
message: `${plural(newlyExposed.current, "employee")} newly exposed in the latest period (${trendWord(newlyExposed.current, newlyExposed.previous)}).`,
})

if (newBreaches.current > 0)
out.push({
severity: newBreaches.current > newBreaches.previous ? "medium" : "info",
message: `${plural(newBreaches.current, "breach")} with new detections in the latest period (${trendWord(newBreaches.current, newBreaches.previous)}).`,
})

return out
}

export function buildFindings(
exposure: ExposureSummary,
compliance: ComplianceSummary,
dataTypes: DataTypeExposure[]
dataTypes: DataTypeExposure[],
deltas: ReportDeltas
): Finding[] {
return [
...alertFindings(compliance),
...exposureFindings(exposure),
...dataFindings(dataTypes),
...deltaFindings(deltas),
].sort((a, b) => ORDER[a.severity] - ORDER[b.severity])
}
7 changes: 5 additions & 2 deletions src/lib/reports/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { getEmployeeBreakdown } from "./by-employee"
import { getTrends } from "./trends"
import { getCompliance } from "./compliance"
import { buildFindings } from "./findings"
import { getDeltas } from "./deltas"
import { EMPTY_FILTERS, type ReportFilters } from "./filters"
import type { ReportData } from "./types"

Expand All @@ -15,24 +16,26 @@ export async function getReportData(
companyId: string,
filters: ReportFilters = EMPTY_FILTERS,
): Promise<ReportData> {
const [exposure, dataTypes, departments, employees, trends, compliance] =
const [exposure, dataTypes, departments, employees, trends, compliance, deltas] =
await Promise.all([
getExposureSummary(companyId, filters),
getDataTypeExposure(companyId, filters),
getDepartmentBreakdown(companyId, filters),
getEmployeeBreakdown(companyId, filters),
getTrends(companyId, filters),
getCompliance(companyId, filters),
getDeltas(companyId, filters),
])

return {
generatedAt: new Date().toISOString(),
findings: buildFindings(exposure, compliance, dataTypes),
findings: buildFindings(exposure, compliance, dataTypes, deltas),
exposure,
dataTypes,
departments,
employees,
trends,
compliance,
deltas,
}
}
Loading