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).
This commit is contained in:
@@ -0,0 +1,268 @@
|
||||
"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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user