Files
go-otel/otel-ui/src/components/CspReportsVisualizer.tsx
T
developer 7a075fbe9a
Build and Publish / Metadata (push) Successful in 10s
Build and Publish / BuildAndDeployAmd64 (push) Failing after 24s
Fix empty CSP summary rendering
2026-09-25 07:40:41 +00:00

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