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).
148 lines
6.2 KiB
TypeScript
148 lines
6.2 KiB
TypeScript
"use client";
|
|
|
|
import { AlertTriangle, Building2, Clock, FileText, Plus, Radio, ServerCog } from "lucide-react";
|
|
import Link from "next/link";
|
|
import { useCurrentUser, useSystemStatus } from "@/hooks/use-auth";
|
|
import { useCompanies } from "@/hooks/use-companies";
|
|
import { useDashboardAnalytics } from "@/hooks/use-analytics";
|
|
import { StatCard } from "@/components/ui/stat-card";
|
|
import { DashboardAnalyticsSection } from "@/components/dashboard/analytics-section";
|
|
import { formatDateTime } from "@/lib/format";
|
|
import type { CompanyResponse } from "@/lib/types";
|
|
|
|
function mostRecentLastRun(companies: CompanyResponse[]): string | null {
|
|
const runs = companies
|
|
.map((c) => c.monitor_configuration?.last_run)
|
|
.filter((v): v is string => Boolean(v));
|
|
if (runs.length === 0) return null;
|
|
return runs.sort().at(-1) ?? null;
|
|
}
|
|
|
|
function nextUpcomingRun(companies: CompanyResponse[]): string | null {
|
|
const runs = companies
|
|
.filter((c) => c.monitor_configuration?.enabled)
|
|
.map((c) => c.monitor_configuration?.next_run)
|
|
.filter((v): v is string => Boolean(v));
|
|
if (runs.length === 0) return null;
|
|
return runs.sort().at(0) ?? null;
|
|
}
|
|
|
|
export default function DashboardPage() {
|
|
const { data: user } = useCurrentUser();
|
|
const { data: systemStatus } = useSystemStatus();
|
|
const { data: companies, isLoading } = useCompanies();
|
|
const { data: analytics } = useDashboardAnalytics();
|
|
|
|
const companyList = companies ?? [];
|
|
const activeCount = companyList.filter((c) => c.status === "active").length;
|
|
const reportCount = companyList.reduce((sum, c) => sum + c.report_count, 0);
|
|
const unreadAlertCount = companyList.reduce((sum, c) => sum + c.unresolved_alert_count, 0);
|
|
|
|
return (
|
|
<div>
|
|
<div className="flex flex-wrap items-start justify-between gap-4">
|
|
<div>
|
|
<h1 className="text-2xl font-semibold text-slate-900">
|
|
Welcome{user ? `, ${user.display_name}` : ""}
|
|
</h1>
|
|
<p className="mt-1 text-sm text-slate-600">
|
|
Here's what's happening across your monitors.
|
|
</p>
|
|
</div>
|
|
<Link
|
|
href="/companies/new"
|
|
className="focus-ring inline-flex items-center gap-2 rounded-md bg-brand-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-brand-700 active:scale-95"
|
|
>
|
|
<Plus className="h-4 w-4" aria-hidden /> Add company
|
|
</Link>
|
|
</div>
|
|
|
|
<div className="mt-6 grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
<StatCard label="Monitored companies" value={companyList.length} icon={Building2} />
|
|
<StatCard label="Active monitors" value={activeCount} icon={Radio} />
|
|
<StatCard label="Reports generated" value={reportCount} icon={FileText} />
|
|
<StatCard label="Unread alerts" value={unreadAlertCount} icon={AlertTriangle} />
|
|
</div>
|
|
|
|
{analytics && <DashboardAnalyticsSection analytics={analytics} />}
|
|
|
|
<div className="mt-6 grid gap-4 lg:grid-cols-2">
|
|
<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">
|
|
<Clock className="h-4 w-4" aria-hidden /> Monitoring schedule
|
|
</div>
|
|
<dl className="mt-3 space-y-2 text-sm">
|
|
<div className="flex justify-between">
|
|
<dt className="text-slate-500">Last successful run</dt>
|
|
<dd className="text-slate-900">{formatDateTime(mostRecentLastRun(companyList))}</dd>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<dt className="text-slate-500">Next scheduled run</dt>
|
|
<dd className="text-slate-900">{formatDateTime(nextUpcomingRun(companyList))}</dd>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
|
|
<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">
|
|
<ServerCog className="h-4 w-4" aria-hidden /> System health
|
|
</div>
|
|
<dl className="mt-3 space-y-2 text-sm">
|
|
{systemStatus?.components.map((c) => (
|
|
<div key={c.name} className="flex justify-between capitalize">
|
|
<dt className="text-slate-500">{c.name}</dt>
|
|
<dd className={c.status === "ok" ? "text-green-700" : "text-red-700"}>
|
|
{c.status}
|
|
</dd>
|
|
</div>
|
|
))}
|
|
<div className="flex justify-between">
|
|
<dt className="text-slate-500">LLM provider</dt>
|
|
<dd className="text-slate-900">{systemStatus?.llm_provider ?? "—"}</dd>
|
|
</div>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-6 rounded-lg border border-slate-200 bg-white p-5">
|
|
<div className="flex items-center justify-between">
|
|
<h2 className="text-sm font-medium text-slate-700">Companies</h2>
|
|
<Link
|
|
href="/companies"
|
|
className="text-sm font-medium text-brand-600 hover:text-brand-700"
|
|
>
|
|
View all
|
|
</Link>
|
|
</div>
|
|
{isLoading ? (
|
|
<p className="mt-4 text-sm text-slate-500">Loading…</p>
|
|
) : companyList.length === 0 ? (
|
|
<div className="mt-4 rounded-md border border-dashed border-slate-300 p-6 text-center">
|
|
<p className="text-sm text-slate-600">You're not monitoring any companies yet.</p>
|
|
<Link
|
|
href="/companies/new"
|
|
className="focus-ring mt-3 inline-block rounded-md bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700"
|
|
>
|
|
Add your first company
|
|
</Link>
|
|
</div>
|
|
) : (
|
|
<ul className="mt-4 divide-y divide-slate-100">
|
|
{companyList.slice(0, 5).map((c) => (
|
|
<li key={c.id} className="flex items-center justify-between py-3 text-sm">
|
|
<Link
|
|
href={`/companies/${c.id}`}
|
|
className="font-medium text-slate-900 hover:text-brand-600"
|
|
>
|
|
{c.name}
|
|
</Link>
|
|
<span className="text-slate-500">{c.status}</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|