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).
269 lines
8.5 KiB
TypeScript
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'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>
|
|
);
|
|
}
|