250 lines
11 KiB
TypeScript
250 lines
11 KiB
TypeScript
import { useMemo, useState } from "react"
|
|
import { RefreshCw, ShieldAlert } from "lucide-react"
|
|
import { useCspReports, useCspSummary } from "../hooks/useCspReports"
|
|
import { useObservabilityData } from "../hooks/useObservabilityData"
|
|
import { formatRelativeTime } from "../lib/timeUtils"
|
|
import { URLLowerHost } from "../lib/csp-url"
|
|
import { Segmented } from "./common/Segmented"
|
|
import { DetailField, SectionLabel } from "./common/DetailField"
|
|
import { FilterBar, type FilterChip } from "./FilterBar"
|
|
import { MasterDetail } from "./MasterDetail"
|
|
import type { CspReport } from "../lib/types"
|
|
|
|
type Window = "24h" | "7d" | "30d" | "all"
|
|
|
|
const WINDOW_HOURS: Record<Window, number | null> = { "24h": 24, "7d": 24 * 7, "30d": 24 * 30, all: null }
|
|
|
|
const dateTime = (iso: string) =>
|
|
new Date(iso).toLocaleString([], {
|
|
month: "short",
|
|
day: "numeric",
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
second: "2-digit",
|
|
})
|
|
|
|
/** Neutral directive badge; tone only escalates for enforced blocks. */
|
|
const DirectiveBadge = ({ report }: { report: CspReport }) => {
|
|
const directive = report.effectiveDirective || report.violatedDirective || "unknown"
|
|
const enforced = report.disposition === "enforce"
|
|
return (
|
|
<span
|
|
className={`inline-flex shrink-0 items-center gap-1 whitespace-nowrap rounded-[6px] border px-1.5 py-0.5 font-[family-name:var(--font-data)] text-[10.5px] ${
|
|
enforced
|
|
? "border-ds-warn/40 bg-ds-warn-bg text-ds-warn"
|
|
: "border-ds-line-strong bg-ds-surface-2 text-ds-text-2"
|
|
}`}
|
|
>
|
|
{directive}
|
|
</span>
|
|
)
|
|
}
|
|
|
|
const StatCard = ({ label, value }: { label: string; value: string | number }) => (
|
|
<div className="flex min-w-0 flex-col gap-1 rounded-[14px] border border-ds-line bg-ds-surface px-4 py-3">
|
|
<span className="truncate font-[family-name:var(--font-data)] text-[22px] leading-tight text-ds-text">{value}</span>
|
|
<span className="text-[11px] uppercase tracking-[0.08em] text-ds-text-3">{label}</span>
|
|
</div>
|
|
)
|
|
|
|
const CspListItem = ({ report }: { report: CspReport }) => (
|
|
<div className="flex w-full flex-col gap-1 px-3 py-2 text-left">
|
|
<div className="flex items-center gap-2">
|
|
<DirectiveBadge report={report} />
|
|
<span className="min-w-0 flex-1 truncate font-[family-name:var(--font-data)] text-[12px] text-ds-text">
|
|
{URLLowerHost(report.blockedUrl ?? "") || report.blockedUrl || "(no blocked URL)"}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-[11px] text-ds-text-3">
|
|
<span className="truncate">{report.site}</span>
|
|
<span className="shrink-0 font-[family-name:var(--font-data)]">{formatRelativeTime(Date.parse(report.timestamp))}</span>
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
const CspReportDetail = ({ report }: { report: CspReport }) => (
|
|
<div className="flex flex-col gap-3 overflow-y-auto p-4">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<DirectiveBadge report={report} />
|
|
{report.disposition && (
|
|
<span className="rounded-[6px] border border-ds-line-strong bg-ds-surface-2 px-1.5 py-0.5 font-[family-name:var(--font-data)] text-[10.5px] text-ds-text-2">
|
|
{report.disposition}
|
|
</span>
|
|
)}
|
|
{report.reportType && (
|
|
<span className="rounded-[6px] border border-ds-line-strong bg-ds-surface-2 px-1.5 py-0.5 font-[family-name:var(--font-data)] text-[10.5px] text-ds-text-2">
|
|
{report.reportType}
|
|
</span>
|
|
)}
|
|
<span className="ml-auto font-[family-name:var(--font-data)] text-[11px] text-ds-text-3">{dateTime(report.timestamp)}</span>
|
|
</div>
|
|
|
|
<div>
|
|
<SectionLabel>Violation</SectionLabel>
|
|
<dl className="mt-1.5 grid grid-cols-1 gap-2 rounded-[9px] border border-ds-fill bg-ds-surface-2 p-3 text-[12px]">
|
|
<DetailField label="Blocked URL" value={report.blockedUrl} />
|
|
<DetailField label="Document" value={report.documentUrl} />
|
|
<DetailField label="Referrer" value={report.referrer} />
|
|
{report.violatedDirective && report.violatedDirective !== report.effectiveDirective && (
|
|
<DetailField label="Violated directive" value={report.violatedDirective} />
|
|
)}
|
|
{report.statusCode ? <DetailField label="Document status" value={String(report.statusCode)} /> : null}
|
|
</dl>
|
|
</div>
|
|
|
|
{report.sample && (
|
|
<div>
|
|
<SectionLabel>Script sample</SectionLabel>
|
|
<pre className="mt-1.5 overflow-x-auto whitespace-pre-wrap break-all rounded-[8px] border border-ds-line bg-ds-surface-2 p-2 font-[family-name:var(--font-data)] text-[11.5px] text-ds-text-2">
|
|
{report.sample}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
|
|
{report.originalPolicy && (
|
|
<div>
|
|
<SectionLabel>Policy</SectionLabel>
|
|
<pre className="mt-1.5 max-h-48 overflow-auto whitespace-pre-wrap break-all rounded-[8px] border border-ds-line bg-ds-surface-2 p-2 font-[family-name:var(--font-data)] text-[11.5px] text-ds-text-2">
|
|
{report.originalPolicy}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<SectionLabel>Context</SectionLabel>
|
|
<dl className="mt-1.5 grid grid-cols-1 gap-2 rounded-[9px] border border-ds-fill bg-ds-surface-2 p-3 text-[12px]">
|
|
<DetailField label="Site" value={report.site} />
|
|
<DetailField label="User agent" value={report.userAgent} />
|
|
<DetailField label="Source IP" value={report.sourceIP} />
|
|
<DetailField label="Report id" value={report.id} />
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
)
|
|
|
|
export function CspReportsVisualizer() {
|
|
const [window, setWindow] = useState<Window>("7d")
|
|
const [site, setSite] = useState<string | null>(null)
|
|
const [directive, setDirective] = useState<string | null>(null)
|
|
const { searchQuery, setSearchQuery } = useObservabilityData()
|
|
|
|
const hours = WINDOW_HOURS[window]
|
|
const { reports, loading, error, refresh } = useCspReports({ site: site ?? undefined, directive: directive ?? undefined, hours: hours ?? undefined })
|
|
const { summary } = useCspSummary(hours ?? 24 * 365)
|
|
|
|
const filtered = useMemo(() => {
|
|
const q = searchQuery.trim().toLowerCase()
|
|
if (!q) return reports
|
|
return reports.filter((r) =>
|
|
`${r.site} ${r.documentUrl} ${r.blockedUrl ?? ""} ${r.referrer ?? ""} ${r.sample ?? ""} ${r.effectiveDirective ?? ""}`
|
|
.toLowerCase()
|
|
.includes(q),
|
|
)
|
|
}, [reports, searchQuery])
|
|
|
|
const selected = filtered[0] ?? null
|
|
|
|
const chip = (id: string, filterKey: string, value: string, onRemove: () => void): FilterChip => ({ id, filterKey, value, onRemove })
|
|
const chips: FilterChip[] = [
|
|
...(searchQuery ? [chip("query", "query", searchQuery, () => setSearchQuery(""))] : []),
|
|
...(site ? [chip("site", "site", site, () => setSite(null))] : []),
|
|
...(directive ? [chip("directive", "directive", directive, () => setDirective(null))] : []),
|
|
]
|
|
const addFilterOptions = [
|
|
...(summary?.bySite ?? []).filter((s) => s.name !== site).slice(0, 6).map((s) => ({ value: `site:${s.name}`, label: `site=${s.name} (${s.count})` })),
|
|
...(summary?.byDirective ?? []).filter((d) => d.name && d.name !== directive).slice(0, 8).map((d) => ({ value: `dir:${d.name}`, label: `directive=${d.name} (${d.count})` })),
|
|
]
|
|
const onAddFilter = (value: string) => {
|
|
if (value.startsWith("site:")) setSite(value.slice(5))
|
|
else if (value.startsWith("dir:")) setDirective(value.slice(4))
|
|
}
|
|
const clearAll = () => {
|
|
setSearchQuery("")
|
|
setSite(null)
|
|
setDirective(null)
|
|
}
|
|
const matchText = `Matching ${filtered.length}${summary ? ` of ${summary.total}` : ""} reports · ${chips.length} filter${chips.length === 1 ? "" : "s"}`
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center gap-2 rounded-[14px] border border-dashed border-ds-line py-20 text-center">
|
|
<ShieldAlert className="h-8 w-8 text-ds-text-3" aria-hidden="true" />
|
|
<p className="text-[14px] font-medium text-ds-text">CSP reports unavailable</p>
|
|
<p className="max-w-sm text-[12.5px] text-ds-text-3">Reports storage is not configured on the collector.</p>
|
|
</div>
|
|
)
|
|
}
|
|
if (loading && reports.length === 0) {
|
|
return <div className="py-16 text-center text-sm text-ds-text-3">Loading CSP reports…</div>
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-5">
|
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
|
<StatCard label="Reports in window" value={summary ? summary.total.toLocaleString() : "—"} />
|
|
<StatCard label="Sites reporting" value={summary ? summary.sites.toLocaleString() : "—"} />
|
|
<StatCard label="Top directive" value={summary?.byDirective?.[0]?.name ?? "—"} />
|
|
<StatCard label="Top blocked host" value={summary?.byBlockedHost?.[0]?.name ?? "—"} />
|
|
</div>
|
|
|
|
<FilterBar
|
|
chips={chips}
|
|
addFilterOptions={addFilterOptions.length > 0 ? addFilterOptions : undefined}
|
|
onAddFilter={addFilterOptions.length > 0 ? onAddFilter : undefined}
|
|
matchText={matchText}
|
|
onClearAll={chips.length > 0 ? clearAll : undefined}
|
|
/>
|
|
|
|
{filtered.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center gap-2 rounded-[14px] border border-ds-line bg-ds-surface py-16 text-center">
|
|
<ShieldAlert className="h-6 w-6 text-ds-text-3" aria-hidden="true" />
|
|
<p className="text-[13px] text-ds-text-3">
|
|
{reports.length === 0
|
|
? "No CSP reports recorded yet. Point report-uri at /v1/csp on the collector."
|
|
: "No reports match your filters."}
|
|
</p>
|
|
</div>
|
|
) : (
|
|
<MasterDetail
|
|
items={filtered}
|
|
getKey={(r) => r.id}
|
|
selectedKey={selected?.id ?? null}
|
|
onSelect={() => {}}
|
|
label="CSP reports"
|
|
storageKey="csp-reports"
|
|
listSize={{ default: 380, min: 300, max: 560 }}
|
|
listHeader={
|
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
|
<h2 className="text-[11px] font-bold uppercase tracking-[0.1em] text-ds-text-3">CSP violations</h2>
|
|
<Segmented
|
|
label="Report time window"
|
|
value={window}
|
|
onChange={setWindow}
|
|
options={[
|
|
{ value: "24h", label: "24h" },
|
|
{ value: "7d", label: "7d" },
|
|
{ value: "30d", label: "30d" },
|
|
{ value: "all", label: "All" },
|
|
]}
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => void refresh()}
|
|
aria-label="Refresh CSP reports"
|
|
title="Refresh CSP reports"
|
|
className="inline-flex h-7 w-7 items-center justify-center rounded-[7px] text-ds-text-3 hover:bg-ds-fill hover:text-ds-text-2"
|
|
>
|
|
<RefreshCw className="h-3.5 w-3.5" aria-hidden="true" />
|
|
</button>
|
|
</div>
|
|
}
|
|
renderItem={(report) => <CspListItem report={report} />}
|
|
renderDetail={(report) => <CspReportDetail report={report} />}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default CspReportsVisualizer
|