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,152 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { AlertTriangle, Check, CheckCheck } from "lucide-react";
|
||||
import { SeverityBadge } from "@/components/ui/badge";
|
||||
import { Select } from "@/components/ui/select";
|
||||
import { useAlerts, useMarkAlertRead, useResolveAlert } from "@/hooks/use-alerts";
|
||||
import { useCompanies } from "@/hooks/use-companies";
|
||||
import { formatRelative } from "@/lib/format";
|
||||
import { SEVERITY_LABELS, SEVERITY_LEVELS, type SeverityLevel } from "@/lib/types";
|
||||
|
||||
export default function AlertsPage() {
|
||||
const [companyId, setCompanyId] = useState<string>("");
|
||||
const [severity, setSeverity] = useState<SeverityLevel | "">("");
|
||||
const [unreadOnly, setUnreadOnly] = useState(false);
|
||||
const [showResolved, setShowResolved] = useState(false);
|
||||
|
||||
const { data: companies } = useCompanies();
|
||||
const { data: alerts, isLoading } = useAlerts({
|
||||
company_id: companyId || undefined,
|
||||
severity: severity || undefined,
|
||||
read: unreadOnly ? false : undefined,
|
||||
resolved: showResolved ? undefined : false,
|
||||
});
|
||||
const markRead = useMarkAlertRead();
|
||||
const resolve = useResolveAlert();
|
||||
|
||||
const companyNameById = useMemo(
|
||||
() => new Map((companies ?? []).map((c) => [c.id, c.name])),
|
||||
[companies],
|
||||
);
|
||||
|
||||
const alertList = alerts ?? [];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-slate-900">Alerts</h1>
|
||||
<p className="mt-1 text-sm text-slate-600">
|
||||
Meaningful changes detected across your monitored companies.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center gap-3 rounded-lg border border-slate-200 bg-white p-4">
|
||||
<Select
|
||||
value={companyId}
|
||||
onValueChange={setCompanyId}
|
||||
placeholder="All companies"
|
||||
ariaLabel="Filter by company"
|
||||
options={[
|
||||
{ value: "", label: "All companies" },
|
||||
...(companies ?? []).map((c) => ({ value: c.id, label: c.name })),
|
||||
]}
|
||||
triggerClassName="px-3 py-1.5 text-sm"
|
||||
/>
|
||||
|
||||
<Select
|
||||
value={severity}
|
||||
onValueChange={(v) => setSeverity(v as SeverityLevel | "")}
|
||||
placeholder="All severities"
|
||||
ariaLabel="Filter by severity"
|
||||
options={[
|
||||
{ value: "", label: "All severities" },
|
||||
...SEVERITY_LEVELS.map((s) => ({ value: s, label: SEVERITY_LABELS[s] })),
|
||||
]}
|
||||
triggerClassName="px-3 py-1.5 text-sm"
|
||||
/>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-slate-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={unreadOnly}
|
||||
onChange={(e) => setUnreadOnly(e.target.checked)}
|
||||
className="focus-ring rounded border-slate-300"
|
||||
/>
|
||||
Unread only
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-slate-600">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showResolved}
|
||||
onChange={(e) => setShowResolved(e.target.checked)}
|
||||
className="focus-ring rounded border-slate-300"
|
||||
/>
|
||||
Show resolved
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<p className="mt-8 text-sm text-slate-500">Loading…</p>
|
||||
) : alertList.length === 0 ? (
|
||||
<div className="mt-8 rounded-lg border border-dashed border-slate-300 bg-white p-10 text-center">
|
||||
<AlertTriangle className="mx-auto h-8 w-8 text-slate-400" aria-hidden />
|
||||
<p className="mt-3 text-sm text-slate-600">No alerts match these filters.</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="mt-6 divide-y divide-slate-100 rounded-lg border border-slate-200 bg-white">
|
||||
{alertList.map((alert) => (
|
||||
<li key={alert.id} className="flex items-start justify-between gap-4 px-4 py-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<SeverityBadge severity={alert.severity} />
|
||||
{!alert.read && (
|
||||
<span className="h-1.5 w-1.5 rounded-full bg-brand-600" aria-label="Unread" />
|
||||
)}
|
||||
{alert.resolved && (
|
||||
<span className="text-xs font-medium text-slate-400">Resolved</span>
|
||||
)}
|
||||
</div>
|
||||
<Link
|
||||
href={`/alerts/${alert.id}`}
|
||||
className="mt-1 block truncate font-medium text-slate-900 hover:text-brand-600"
|
||||
>
|
||||
{alert.title}
|
||||
</Link>
|
||||
<p className="mt-0.5 text-sm text-slate-600">
|
||||
{companyNameById.get(alert.company_id) ?? "Unknown company"} ·{" "}
|
||||
{formatRelative(alert.created_at)} · {Math.round(alert.confidence * 100)}%
|
||||
confidence
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{!alert.read && (
|
||||
<button
|
||||
title="Mark read"
|
||||
onClick={() => markRead.mutate(alert.id)}
|
||||
className="focus-ring rounded-md p-2 text-slate-500 hover:bg-slate-100"
|
||||
>
|
||||
<Check className="h-4 w-4" aria-hidden />
|
||||
<span className="sr-only">Mark read</span>
|
||||
</button>
|
||||
)}
|
||||
{!alert.resolved && (
|
||||
<button
|
||||
title="Resolve"
|
||||
onClick={() => resolve.mutate(alert.id)}
|
||||
className="focus-ring rounded-md p-2 text-slate-500 hover:bg-slate-100"
|
||||
>
|
||||
<CheckCheck className="h-4 w-4" aria-hidden />
|
||||
<span className="sr-only">Resolve</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user