Files
CIAgent/apps/web/components/dashboard/analytics-section.tsx
T
saksham 1a4c80958f Initial commit: CI Agent competitive-intelligence monitoring app
FastAPI + Celery + Next.js + Postgres/Redis app with company monitoring,
source collection, LLM-based change analysis, enrichment, and account
security (Turnstile, escalating lockout, email verification).
2026-08-05 10:48:20 -04:00

269 lines
8.5 KiB
TypeScript

"use client";
import Link from "next/link";
import {
Bar,
BarChart,
CartesianGrid,
Cell,
Legend,
LabelList,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from "recharts";
import { BarChart3, type LucideIcon, Radar } from "lucide-react";
import { SeverityBadge } from "@/components/ui/badge";
import { formatRelative } from "@/lib/format";
import {
CHANGE_TYPE_LABELS,
SEVERITY_LABELS,
SEVERITY_LEVELS,
type DashboardAnalytics,
type SeverityLevel,
} from "@/lib/types";
// Same hex values as tailwind.config.ts's `severity` ramp - a reserved
// status scale (good -> critical), never reused as generic categorical
// series color. Kept in sync manually since chart libraries need literal
// values, not Tailwind classes.
const SEVERITY_COLORS: Record<SeverityLevel, string> = {
critical: "#dc2626",
high: "#ea580c",
medium: "#ca8a04",
low: "#65a30d",
};
const RUN_STATUS_COLORS = {
successful: "#16a34a",
failed: "#dc2626",
other: "#94a3b8",
};
const AXIS_TICK_STYLE = { fill: "#64748b", fontSize: 12 };
const GRID_STROKE = "#e2e8f0";
function ChartCard({
title,
icon: Icon,
children,
}: {
title: string;
icon: LucideIcon;
children: React.ReactNode;
}) {
return (
<div className="rounded-lg border border-slate-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm font-medium text-slate-700">
<Icon className="h-4 w-4" aria-hidden /> {title}
</div>
<div className="mt-4">{children}</div>
</div>
);
}
function EmptyChartState({ message }: { message: string }) {
return (
<div className="flex h-[180px] items-center justify-center text-sm text-slate-400">
{message}
</div>
);
}
function ChangesByTypeChart({ data }: { data: DashboardAnalytics["changes_by_type"] }) {
const rows = Object.entries(data)
.map(([key, value]) => ({ key, label: CHANGE_TYPE_LABELS[key] ?? key, value }))
.filter((r) => r.value > 0)
.sort((a, b) => b.value - a.value);
if (rows.length === 0) {
return <EmptyChartState message="No changes detected in the last 30 days yet." />;
}
return (
<ResponsiveContainer width="100%" height={Math.max(140, rows.length * 36)}>
<BarChart data={rows} layout="vertical" margin={{ left: 8, right: 24, top: 4, bottom: 4 }}>
<CartesianGrid horizontal={false} stroke={GRID_STROKE} />
<XAxis type="number" allowDecimals={false} tick={AXIS_TICK_STYLE} axisLine={false} />
<YAxis
type="category"
dataKey="label"
width={130}
tick={AXIS_TICK_STYLE}
axisLine={false}
tickLine={false}
/>
<Tooltip
cursor={{ fill: "#f8fafc" }}
contentStyle={{ fontSize: 12, borderRadius: 8, borderColor: "#e2e8f0" }}
formatter={(value: number) => [value, "Changes"]}
/>
<Bar dataKey="value" fill="#3766f7" radius={[0, 4, 4, 0]} maxBarSize={22}>
<LabelList dataKey="value" position="right" style={{ fill: "#334155", fontSize: 12 }} />
</Bar>
</BarChart>
</ResponsiveContainer>
);
}
function AlertsBySeverityChart({ data }: { data: DashboardAnalytics["alerts_by_severity"] }) {
const total = SEVERITY_LEVELS.reduce((sum, s) => sum + (data[s] ?? 0), 0);
if (total === 0) {
return <EmptyChartState message="No alerts in the last 30 days yet." />;
}
const rows = SEVERITY_LEVELS.map((severity) => ({
severity,
label: SEVERITY_LABELS[severity],
value: data[severity] ?? 0,
}));
return (
<ResponsiveContainer width="100%" height={160}>
<BarChart data={rows} layout="vertical" margin={{ left: 8, right: 24, top: 4, bottom: 4 }}>
<CartesianGrid horizontal={false} stroke={GRID_STROKE} />
<XAxis type="number" allowDecimals={false} tick={AXIS_TICK_STYLE} axisLine={false} />
<YAxis
type="category"
dataKey="label"
width={70}
tick={AXIS_TICK_STYLE}
axisLine={false}
tickLine={false}
/>
<Tooltip
cursor={{ fill: "#f8fafc" }}
contentStyle={{ fontSize: 12, borderRadius: 8, borderColor: "#e2e8f0" }}
formatter={(value: number) => [value, "Alerts"]}
/>
<Bar dataKey="value" radius={[0, 4, 4, 0]} maxBarSize={22}>
{rows.map((row) => (
<Cell key={row.severity} fill={SEVERITY_COLORS[row.severity]} />
))}
<LabelList dataKey="value" position="right" style={{ fill: "#334155", fontSize: 12 }} />
</Bar>
</BarChart>
</ResponsiveContainer>
);
}
function RunsByDayChart({ data }: { data: DashboardAnalytics["runs_by_day"] }) {
if (data.length === 0) {
return <EmptyChartState message="No monitoring runs in the last 30 days yet." />;
}
const rows = data.map((d) => ({
...d,
label: new Date(d.date).toLocaleDateString(undefined, { month: "short", day: "numeric" }),
}));
return (
<ResponsiveContainer width="100%" height={200}>
<BarChart data={rows} margin={{ left: 0, right: 8, top: 4, bottom: 4 }} barGap={2}>
<CartesianGrid vertical={false} stroke={GRID_STROKE} />
<XAxis
dataKey="label"
tick={AXIS_TICK_STYLE}
axisLine={false}
tickLine={false}
interval="preserveStartEnd"
/>
<YAxis allowDecimals={false} tick={AXIS_TICK_STYLE} axisLine={false} tickLine={false} />
<Tooltip
cursor={{ fill: "#f8fafc" }}
contentStyle={{ fontSize: 12, borderRadius: 8, borderColor: "#e2e8f0" }}
/>
<Legend wrapperStyle={{ fontSize: 12 }} iconType="square" iconSize={10} />
<Bar
dataKey="successful"
stackId="runs"
name="Successful"
fill={RUN_STATUS_COLORS.successful}
radius={[0, 0, 0, 0]}
maxBarSize={20}
/>
<Bar
dataKey="failed"
stackId="runs"
name="Failed"
fill={RUN_STATUS_COLORS.failed}
maxBarSize={20}
/>
<Bar
dataKey="other"
stackId="runs"
name="Other"
fill={RUN_STATUS_COLORS.other}
radius={[4, 4, 0, 0]}
maxBarSize={20}
/>
</BarChart>
</ResponsiveContainer>
);
}
function RecentSignalsFeed({ signals }: { signals: DashboardAnalytics["recent_signals"] }) {
if (signals.length === 0) {
return (
<p className="mt-4 text-sm text-slate-500">
No signals detected yet once a monitoring run finds a real change, it&apos;ll show up
here.
</p>
);
}
return (
<ul className="mt-4 divide-y divide-slate-100">
{signals.map((signal) => (
<li key={signal.id} className="flex items-start justify-between gap-4 py-3 text-sm">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<SeverityBadge severity={signal.severity} />
<span className="text-xs font-medium text-slate-500">
{CHANGE_TYPE_LABELS[signal.change_type] ?? signal.change_type}
</span>
</div>
<p className="mt-1 truncate text-slate-900">{signal.summary}</p>
<Link
href={`/companies/${signal.company_id}`}
className="text-xs text-brand-600 hover:text-brand-700"
>
{signal.company_name}
</Link>
</div>
<span className="shrink-0 whitespace-nowrap text-xs text-slate-500">
{formatRelative(signal.created_at)}
</span>
</li>
))}
</ul>
);
}
export function DashboardAnalyticsSection({ analytics }: { analytics: DashboardAnalytics }) {
return (
<div className="mt-6 animate-fade-in space-y-6">
<div className="grid gap-4 lg:grid-cols-2">
<ChartCard title="Changes detected (last 30 days)" icon={Radar}>
<ChangesByTypeChart data={analytics.changes_by_type} />
</ChartCard>
<ChartCard title="Alerts by severity (last 30 days)" icon={BarChart3}>
<AlertsBySeverityChart data={analytics.alerts_by_severity} />
</ChartCard>
</div>
<ChartCard title="Monitoring runs (last 30 days)" icon={BarChart3}>
<RunsByDayChart data={analytics.runs_by_day} />
</ChartCard>
<div className="rounded-lg border border-slate-200 bg-white p-5">
<div className="flex items-center gap-2 text-sm font-medium text-slate-700">
<Radar className="h-4 w-4" aria-hidden /> Recent intelligence signals
</div>
<RecentSignalsFeed signals={analytics.recent_signals} />
</div>
</div>
);
}