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).
1270 lines
58 KiB
TypeScript
1270 lines
58 KiB
TypeScript
"use client";
|
|
|
|
import Link from "next/link";
|
|
import { useRouter, useSearchParams } from "next/navigation";
|
|
import { Suspense, use, useState } from "react";
|
|
import {
|
|
AlertTriangle,
|
|
Check,
|
|
CheckCheck,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Loader2,
|
|
Plus,
|
|
} from "lucide-react";
|
|
import { CompanyPillLink } from "@/components/ui/company-pill";
|
|
import { CopyableEmail } from "@/components/ui/copyable-email";
|
|
import { CompanyStatusBadge, SeverityBadge } from "@/components/ui/badge";
|
|
import { NotificationChannelBox } from "@/components/ui/notification-channel-box";
|
|
import { Select } from "@/components/ui/select";
|
|
import { useAlerts, useMarkAlertRead, useResolveAlert } from "@/hooks/use-alerts";
|
|
import { useSystemStatus } from "@/hooks/use-auth";
|
|
import {
|
|
useCompanies,
|
|
useCompany,
|
|
useDeleteCompany,
|
|
useSetCompanyPaused,
|
|
useUpdateMonitorConfiguration,
|
|
} from "@/hooks/use-companies";
|
|
import { useNotificationDestinations } from "@/hooks/use-notification-destinations";
|
|
import {
|
|
useCreateSource,
|
|
useDeleteSource,
|
|
useSources,
|
|
useTestSource,
|
|
useUpdateSource,
|
|
} from "@/hooks/use-sources";
|
|
import { useCompanyRuns, useRunCompanyNow } from "@/hooks/use-monitoring-runs";
|
|
import { useCompanyReports, useGenerateReport } from "@/hooks/use-reports";
|
|
import { useSnapshots } from "@/hooks/use-snapshots";
|
|
import type { MonitoringFrequency } from "@/lib/types";
|
|
import {
|
|
FREQUENCY_LABELS,
|
|
MONITORING_FREQUENCIES,
|
|
SEVERITY_LABELS,
|
|
SEVERITY_LEVELS,
|
|
} from "@/lib/types";
|
|
import {
|
|
companyNameFromUrl,
|
|
formatDateTime,
|
|
formatEnrichmentReason,
|
|
formatMoney,
|
|
formatRelative,
|
|
summarizeEnrichmentError,
|
|
} from "@/lib/format";
|
|
|
|
const TABS = [
|
|
"Overview",
|
|
"Enrichment",
|
|
"Latest report",
|
|
"Alerts",
|
|
"Sources",
|
|
"Monitoring history",
|
|
"Snapshots",
|
|
"Configuration",
|
|
] as const;
|
|
type Tab = (typeof TABS)[number];
|
|
|
|
const TAB_DESCRIPTIONS: Record<Tab, string> = {
|
|
Overview: "A snapshot of this company — description, competitors, key details, and its monitoring schedule.",
|
|
Enrichment: "Rich company data — funding, leadership, products, and customers.",
|
|
"Latest report":
|
|
"The most recent AI-generated competitive intelligence report, grounded in everything collected so far.",
|
|
Alerts:
|
|
"Notable changes detected for this company, ranked by severity, with actions to mark them read or resolved.",
|
|
Sources: "Every data source being monitored for this company, and how often each one is checked.",
|
|
"Monitoring history":
|
|
"A log of every monitoring run for this company — what it collected and whether it succeeded.",
|
|
Snapshots:
|
|
"The raw content collected from each source on each run, so you can see exactly what was captured.",
|
|
Configuration: "Scheduling, severity threshold, and notification settings for this company.",
|
|
};
|
|
|
|
function charCount(text: string | null): string {
|
|
return `${(text ?? "").length.toLocaleString()} chars`;
|
|
}
|
|
|
|
const RUN_STATUS_CLASSES: Record<string, string> = {
|
|
queued: "bg-slate-100 text-slate-700",
|
|
running: "bg-blue-100 text-blue-700",
|
|
successful: "bg-green-100 text-green-700",
|
|
partial: "bg-amber-100 text-amber-700",
|
|
failed: "bg-red-100 text-red-700",
|
|
};
|
|
|
|
const ERROR_TONE_CLASSES = {
|
|
amber: {
|
|
box: "border-amber-200 bg-amber-50 text-amber-800",
|
|
button: "border-amber-300 text-amber-800 hover:bg-amber-100",
|
|
},
|
|
red: {
|
|
box: "border-red-200 bg-red-50 text-red-800",
|
|
button: "border-red-300 text-red-800 hover:bg-red-100",
|
|
},
|
|
} as const;
|
|
|
|
function EnrichmentErrorSummary({
|
|
tone,
|
|
summary,
|
|
errors,
|
|
}: {
|
|
tone: keyof typeof ERROR_TONE_CLASSES;
|
|
summary: string;
|
|
errors: Record<string, string>;
|
|
}) {
|
|
const [expanded, setExpanded] = useState(false);
|
|
const sections = Object.entries(errors);
|
|
const classes = ERROR_TONE_CLASSES[tone];
|
|
|
|
return (
|
|
<div className={`rounded-lg border p-4 text-sm ${classes.box}`}>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<p>{summary}</p>
|
|
{sections.length > 0 && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setExpanded((e) => !e)}
|
|
className={`focus-ring shrink-0 whitespace-nowrap rounded-md border bg-white/60 px-2 py-1 text-xs font-medium transition-colors duration-200 ${classes.button}`}
|
|
>
|
|
{expanded ? "Hide details" : "View details"}
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div
|
|
className={`grid transition-[grid-template-rows] duration-300 ease-out ${expanded ? "grid-rows-[1fr] mt-3" : "grid-rows-[0fr]"}`}
|
|
>
|
|
<div className="overflow-hidden">
|
|
<ul className="space-y-2 border-t border-current/20 pt-3 text-xs">
|
|
{sections.map(([section, raw]) => (
|
|
<li key={section}>
|
|
<p className="font-medium">{section}</p>
|
|
<p className="mt-0.5 opacity-80">{summarizeEnrichmentError(raw)}</p>
|
|
<pre className="mt-1 whitespace-pre-wrap break-words opacity-60">{raw}</pre>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function CompanyDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
|
const { id } = use(params);
|
|
return (
|
|
<Suspense fallback={<p className="text-sm text-slate-500">Loading…</p>}>
|
|
<CompanyDetailPageInner id={id} />
|
|
</Suspense>
|
|
);
|
|
}
|
|
|
|
function CompanyDetailPageInner({ id }: { id: string }) {
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const requestedTab = searchParams.get("tab");
|
|
const initialTab = (TABS as readonly string[]).includes(requestedTab ?? "")
|
|
? (requestedTab as Tab)
|
|
: "Overview";
|
|
|
|
const { data: company, isLoading } = useCompany(id);
|
|
const { data: allCompanies } = useCompanies();
|
|
const setPaused = useSetCompanyPaused();
|
|
const deleteCompany = useDeleteCompany();
|
|
const updateMonitor = useUpdateMonitorConfiguration(id);
|
|
const { data: sources, isLoading: sourcesLoading } = useSources(id);
|
|
const createSource = useCreateSource(id);
|
|
const deleteSource = useDeleteSource(id);
|
|
const testSource = useTestSource(id);
|
|
const updateSource = useUpdateSource(id);
|
|
const { data: runs, isLoading: runsLoading } = useCompanyRuns(id);
|
|
const runNow = useRunCompanyNow(id);
|
|
const { data: reports, isLoading: reportsLoading } = useCompanyReports(id);
|
|
const generateReport = useGenerateReport(id);
|
|
const { data: snapshots, isLoading: snapshotsLoading } = useSnapshots(id);
|
|
const { data: alerts, isLoading: alertsLoading } = useAlerts({ company_id: id });
|
|
const markAlertRead = useMarkAlertRead();
|
|
const resolveAlert = useResolveAlert();
|
|
const { data: notificationDestinations } = useNotificationDestinations();
|
|
const { data: systemStatus } = useSystemStatus();
|
|
const [tab, setTab] = useState<Tab>(initialTab);
|
|
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
|
const [newSourceName, setNewSourceName] = useState("");
|
|
const [newSourceUrl, setNewSourceUrl] = useState("");
|
|
const [testResults, setTestResults] = useState<Record<string, string>>({});
|
|
const [expandedSnapshots, setExpandedSnapshots] = useState<Set<string>>(new Set());
|
|
const [frequencyOverride, setFrequencyOverride] = useState<string | null>(null);
|
|
const [severityOverride, setSeverityOverride] = useState<string | null>(null);
|
|
|
|
if (isLoading) {
|
|
return <p className="text-sm text-slate-500">Loading…</p>;
|
|
}
|
|
|
|
if (!company) {
|
|
return <p className="text-sm text-slate-500">Company not found.</p>;
|
|
}
|
|
|
|
const config = company.monitor_configuration;
|
|
// "Running" covers the whole onboarding/run chain, not just the
|
|
// monitoring-run row itself: enrichment is a separate fire-and-forget
|
|
// task fired at company creation, and its own tab shouldn't look done
|
|
// (nor should Pause/Delete be usable) while it's still in flight either.
|
|
const isRunning =
|
|
(runs?.some((r) => r.status === "queued" || r.status === "running") ?? false) ||
|
|
company.enrichment?.status === "pending";
|
|
const returnTo = `/companies/${id}?tab=${encodeURIComponent(tab)}`;
|
|
|
|
const monitoredByName = new Map(
|
|
(allCompanies ?? [])
|
|
.filter((c) => c.id !== company.id)
|
|
.map((c) => [c.name.trim().toLowerCase(), c.id] as const),
|
|
);
|
|
|
|
const existingCompetitorKeys = new Set(company.competitors.map((c) => c.trim().toLowerCase()));
|
|
const enrichmentCompetitors =
|
|
(company.enrichment?.data.competitors as { name?: string }[] | undefined) ?? [];
|
|
const suggestedCompetitors = [
|
|
...new Set(
|
|
enrichmentCompetitors
|
|
.map((c) => companyNameFromUrl((c.name ?? "").trim()))
|
|
.filter((name) => name && !existingCompetitorKeys.has(name.toLowerCase())),
|
|
),
|
|
];
|
|
|
|
const companyDestinations = (notificationDestinations ?? []).filter((d) =>
|
|
d.companies.some((c) => c.id === company.id),
|
|
);
|
|
const emailDestination = companyDestinations.find((d) => d.type === "email");
|
|
const smsDestination = companyDestinations.find((d) => d.type === "sms");
|
|
|
|
return (
|
|
<div>
|
|
<Link href="/companies" className="text-sm text-slate-500 hover:text-slate-700">
|
|
← Back to companies
|
|
</Link>
|
|
|
|
<div className="mt-2 flex flex-wrap items-start justify-between gap-4">
|
|
<div>
|
|
<div className="flex items-center gap-3">
|
|
<h1 className="text-2xl font-semibold text-slate-900">{company.name}</h1>
|
|
<CompanyStatusBadge status={company.status} />
|
|
</div>
|
|
{company.official_website && (
|
|
<a
|
|
href={company.official_website}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-sm text-brand-600 hover:text-brand-700"
|
|
>
|
|
{company.official_website}
|
|
</a>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={() => runNow.mutate()}
|
|
disabled={runNow.isPending || isRunning}
|
|
className={`focus-ring inline-flex items-center gap-1.5 rounded-md border px-3 py-2 text-sm font-medium transition-colors duration-200 disabled:cursor-not-allowed ${
|
|
isRunning
|
|
? "border-green-700 bg-green-700 text-white"
|
|
: "border-slate-300 text-slate-700 hover:border-green-300 hover:bg-green-50 hover:text-green-700 disabled:opacity-60"
|
|
}`}
|
|
>
|
|
{isRunning && <Loader2 className="h-4 w-4 animate-spin" aria-hidden />}
|
|
{isRunning ? "Running…" : "Run now"}
|
|
</button>
|
|
<button
|
|
onClick={() =>
|
|
setPaused.mutate({ companyId: company.id, paused: company.status === "active" })
|
|
}
|
|
disabled={setPaused.isPending || isRunning}
|
|
className={`focus-ring rounded-md border border-slate-300 px-3 py-2 text-sm font-medium text-slate-700 transition-[opacity,color,background-color,border-color] duration-200 disabled:cursor-not-allowed ${
|
|
isRunning
|
|
? "opacity-40"
|
|
: "hover:border-amber-300 hover:bg-amber-50 hover:text-amber-700 disabled:opacity-60"
|
|
}`}
|
|
>
|
|
<span key={company.status} className="inline-block animate-fade-in">
|
|
{company.status === "active" ? "Pause Monitoring" : "Resume Monitoring"}
|
|
</span>
|
|
</button>
|
|
<div
|
|
className={`grid transition-[grid-template-columns] duration-200 ease-out ${
|
|
confirmingDelete ? "grid-cols-[1fr]" : "grid-cols-[0fr]"
|
|
}`}
|
|
>
|
|
<div className="overflow-hidden">
|
|
<div
|
|
className={`flex items-center gap-2 whitespace-nowrap text-sm transition-opacity duration-200 ${
|
|
confirmingDelete ? "opacity-100 delay-100" : "opacity-0"
|
|
}`}
|
|
>
|
|
<span>Delete this company?</span>
|
|
<button
|
|
onClick={async () => {
|
|
await deleteCompany.mutateAsync(company.id);
|
|
router.push("/companies");
|
|
}}
|
|
disabled={deleteCompany.isPending || isRunning}
|
|
className={`focus-ring rounded-md bg-red-600 px-3 py-2 font-semibold text-white transition-[opacity,background-color] duration-200 disabled:cursor-not-allowed ${
|
|
isRunning ? "opacity-40" : "hover:bg-red-700"
|
|
}`}
|
|
>
|
|
Yes, delete
|
|
</button>
|
|
<button
|
|
onClick={() => setConfirmingDelete(false)}
|
|
className="focus-ring rounded-md px-3 py-2 text-slate-600 transition-colors duration-200 hover:bg-slate-100"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div
|
|
className={`grid transition-[grid-template-columns] duration-200 ease-out ${
|
|
confirmingDelete ? "grid-cols-[0fr]" : "grid-cols-[1fr]"
|
|
}`}
|
|
>
|
|
<div className="overflow-hidden">
|
|
<button
|
|
onClick={() => setConfirmingDelete(true)}
|
|
disabled={isRunning}
|
|
className={`focus-ring whitespace-nowrap rounded-md border border-slate-300 px-3 py-2 text-sm font-medium text-red-600 transition-[opacity,color,background-color] duration-200 disabled:cursor-not-allowed ${
|
|
confirmingDelete
|
|
? "opacity-0"
|
|
: isRunning
|
|
? "opacity-40 delay-100"
|
|
: "opacity-100 delay-100 hover:bg-red-50"
|
|
}`}
|
|
>
|
|
Delete
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-6 border-b border-slate-200">
|
|
<nav className="-mb-px flex flex-wrap gap-4">
|
|
{TABS.map((t) => (
|
|
<button
|
|
key={t}
|
|
onClick={() => setTab(t)}
|
|
className={`focus-ring whitespace-nowrap border-b-2 px-1 py-2 text-sm font-medium ${
|
|
tab === t
|
|
? "border-brand-600 text-brand-600"
|
|
: "border-transparent text-slate-500 hover:text-slate-700"
|
|
}`}
|
|
>
|
|
{t}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
</div>
|
|
|
|
<div key={tab} className="mt-6 animate-fade-in">
|
|
<p className="mb-4 rounded-lg border border-slate-200 bg-white p-3 text-sm text-slate-600">
|
|
{TAB_DESCRIPTIONS[tab]}
|
|
</p>
|
|
|
|
{tab === "Overview" && (
|
|
<div className="grid gap-6 lg:grid-cols-3">
|
|
<div className="space-y-4 lg:col-span-2">
|
|
<section className="rounded-lg border border-slate-200 bg-white p-5">
|
|
<h2 className="text-sm font-medium text-slate-700">Monitoring focus</h2>
|
|
<p className="mt-2 text-sm text-slate-600">
|
|
{company.monitoring_focus || "No specific focus provided — general monitoring."}
|
|
</p>
|
|
</section>
|
|
{company.description && (
|
|
<section className="rounded-lg border border-slate-200 bg-white p-5">
|
|
<h2 className="text-sm font-medium text-slate-700">Description</h2>
|
|
<p className="mt-2 text-sm text-slate-600">{company.description}</p>
|
|
</section>
|
|
)}
|
|
<section className="rounded-lg border border-slate-200 bg-white p-5">
|
|
<h2 className="text-sm font-medium text-slate-700">Competitors</h2>
|
|
{company.competitors.length === 0 && suggestedCompetitors.length === 0 ? (
|
|
<p className="mt-2 text-sm text-slate-500">None listed.</p>
|
|
) : (
|
|
<ul className="mt-2 flex flex-wrap gap-2">
|
|
{company.competitors.map((c) => (
|
|
<li key={c}>
|
|
<CompanyPillLink name={c} monitoredByName={monitoredByName} returnTo={returnTo} />
|
|
</li>
|
|
))}
|
|
{suggestedCompetitors.map((c) => (
|
|
<li key={`suggested-${c}`}>
|
|
<CompanyPillLink
|
|
name={c}
|
|
subtitle="suggested"
|
|
monitoredByName={monitoredByName}
|
|
returnTo={returnTo}
|
|
/>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
</div>
|
|
<aside className="space-y-4">
|
|
<section className="rounded-lg border border-slate-200 bg-white p-5 text-sm">
|
|
<h2 className="font-medium text-slate-700">Details</h2>
|
|
<dl className="mt-3 space-y-3">
|
|
<div>
|
|
<dt className="text-xs text-slate-500">Industry</dt>
|
|
<dd className="mt-0.5 break-words">{company.industry || "—"}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-xs text-slate-500">Headquarters</dt>
|
|
<dd className="mt-0.5 break-words">{company.headquarters || "—"}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-xs text-slate-500">Country</dt>
|
|
<dd className="mt-0.5 break-words">{company.country || "—"}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-xs text-slate-500">Region</dt>
|
|
<dd className="mt-0.5 break-words">{company.region || "—"}</dd>
|
|
</div>
|
|
<div>
|
|
<dt className="text-xs text-slate-500">Aliases</dt>
|
|
<dd className="mt-0.5 break-words">{company.aliases.join(", ") || "—"}</dd>
|
|
</div>
|
|
{Object.entries(company.public_identifiers).map(([key, value]) => (
|
|
<div key={key}>
|
|
<dt className="text-xs capitalize text-slate-500">
|
|
{key.replace(/_/g, " ")}
|
|
</dt>
|
|
<dd className="mt-0.5 break-words">{value}</dd>
|
|
</div>
|
|
))}
|
|
</dl>
|
|
</section>
|
|
<section className="rounded-lg border border-slate-200 bg-white p-5 text-sm">
|
|
<h2 className="font-medium text-slate-700">Schedule</h2>
|
|
<dl className="mt-3 space-y-2">
|
|
<div className="flex justify-between">
|
|
<dt className="text-slate-500">Frequency</dt>
|
|
<dd>{config ? FREQUENCY_LABELS[config.frequency_type] : "—"}</dd>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<dt className="text-slate-500">Next run</dt>
|
|
<dd>{formatDateTime(config?.next_run)}</dd>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<dt className="text-slate-500">Last run</dt>
|
|
<dd>{formatDateTime(config?.last_run)}</dd>
|
|
</div>
|
|
</dl>
|
|
</section>
|
|
</aside>
|
|
</div>
|
|
)}
|
|
|
|
{tab === "Enrichment" && (
|
|
<div className="space-y-4">
|
|
{!company.enrichment ? (
|
|
<div className="rounded-lg border border-dashed border-slate-300 bg-white p-10 text-center text-sm text-slate-500">
|
|
Company enrichment (NinjaPear) isn't configured for this app — no data
|
|
provider key is set.
|
|
</div>
|
|
) : company.enrichment.status === "pending" ? (
|
|
<div className="rounded-lg border border-slate-200 bg-white p-10 text-center text-sm text-slate-500">
|
|
Enriching this company from external data sources… this can take a few minutes.
|
|
</div>
|
|
) : (
|
|
<>
|
|
{company.enrichment.status === "partial" && (
|
|
<EnrichmentErrorSummary
|
|
tone="amber"
|
|
summary={`Some enrichment sections couldn't be fetched: ${Object.keys(
|
|
company.enrichment.errors,
|
|
).join(", ")}.`}
|
|
errors={company.enrichment.errors}
|
|
/>
|
|
)}
|
|
{company.enrichment.status === "failed" && (
|
|
<EnrichmentErrorSummary
|
|
tone="red"
|
|
summary="Enrichment failed for every section — no external data is available for this company."
|
|
errors={company.enrichment.errors}
|
|
/>
|
|
)}
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<section className="rounded-lg border border-slate-200 bg-white p-5 text-sm">
|
|
<h2 className="font-medium text-slate-700">Company snapshot</h2>
|
|
<dl className="mt-3 space-y-2">
|
|
<div className="flex justify-between">
|
|
<dt className="text-slate-500">Employees</dt>
|
|
<dd>{String(company.enrichment.data.employee_count ?? "—")}</dd>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<dt className="text-slate-500">Founded</dt>
|
|
<dd>{String(company.enrichment.data.founded_year ?? "—")}</dd>
|
|
</div>
|
|
</dl>
|
|
{Array.isArray(company.enrichment.data.specialties) &&
|
|
(company.enrichment.data.specialties as string[]).length > 0 && (
|
|
<>
|
|
<h3 className="mt-4 text-xs font-medium uppercase text-slate-500">
|
|
Specialties
|
|
</h3>
|
|
<ul className="mt-1 flex flex-wrap gap-1.5">
|
|
{(company.enrichment.data.specialties as string[]).map((s) => (
|
|
<li
|
|
key={s}
|
|
className="rounded-full bg-slate-100 px-2.5 py-0.5 text-xs text-slate-700"
|
|
>
|
|
{s}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</>
|
|
)}
|
|
</section>
|
|
|
|
<section className="rounded-lg border border-slate-200 bg-white p-5 text-sm">
|
|
<h2 className="font-medium text-slate-700">Funding</h2>
|
|
{(() => {
|
|
const funding = company.enrichment.data.funding as
|
|
| { total_raised?: string; rounds?: Record<string, unknown>[] }
|
|
| undefined;
|
|
if (!funding || (!funding.total_raised && !funding.rounds?.length)) {
|
|
return <p className="mt-2 text-slate-500">No funding data found.</p>;
|
|
}
|
|
return (
|
|
<>
|
|
{funding.total_raised && (
|
|
<p className="mt-2 text-slate-600">
|
|
Total raised:{" "}
|
|
<span className="font-medium">
|
|
{formatMoney(funding.total_raised)}
|
|
</span>
|
|
</p>
|
|
)}
|
|
<ul className="mt-2 space-y-2">
|
|
{(funding.rounds ?? []).map((r, i) => (
|
|
<li
|
|
key={i}
|
|
className="border-t border-slate-100 pt-2 first:border-0 first:pt-0"
|
|
>
|
|
<p className="text-slate-700">
|
|
{String(r.round_name ?? "Round")} —{" "}
|
|
{r.amount ? formatMoney(String(r.amount)) : "—"}
|
|
{r.date ? ` (${String(r.date)})` : ""}
|
|
</p>
|
|
{Array.isArray(r.investors) &&
|
|
(r.investors as string[]).length > 0 && (
|
|
<p className="text-xs text-slate-500">
|
|
Investors: {(r.investors as string[]).join(", ")}
|
|
</p>
|
|
)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</>
|
|
);
|
|
})()}
|
|
</section>
|
|
</div>
|
|
|
|
<section className="rounded-lg border border-slate-200 bg-white p-5 text-sm">
|
|
<h2 className="font-medium text-slate-700">Leadership team</h2>
|
|
{!Array.isArray(company.enrichment.data.leadership_team) ||
|
|
(company.enrichment.data.leadership_team as unknown[]).length === 0 ? (
|
|
<p className="mt-2 text-slate-500">No leadership team data found.</p>
|
|
) : (
|
|
<ul className="mt-2">
|
|
{(
|
|
company.enrichment.data.leadership_team as Record<string, unknown>[]
|
|
).map((m, i) => (
|
|
<li key={i}>
|
|
{i > 0 && <hr className="my-2 border-t border-slate-100 mx-3" />}
|
|
<div className="flex items-center justify-between gap-4 py-1">
|
|
<div>
|
|
<p className="text-slate-800">{String(m.name ?? "")}</p>
|
|
{m.title ? (
|
|
<p className="text-xs text-slate-500">{String(m.title)}</p>
|
|
) : null}
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-2 text-xs">
|
|
{m.work_email ? <CopyableEmail email={String(m.work_email)} /> : null}
|
|
{m.work_email && m.profile_url ? (
|
|
<span className="text-slate-300">-</span>
|
|
) : null}
|
|
{m.profile_url ? (
|
|
<a
|
|
href={String(m.profile_url)}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-blue-600 hover:underline"
|
|
>
|
|
Profile
|
|
</a>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<section className="rounded-lg border border-slate-200 bg-white p-5 text-sm">
|
|
<h2 className="font-medium text-slate-700">Customers</h2>
|
|
{!Array.isArray(company.enrichment.data.customers) ||
|
|
(company.enrichment.data.customers as unknown[]).length === 0 ? (
|
|
<p className="mt-2 text-slate-500">None found.</p>
|
|
) : (
|
|
<ul className="mt-2 flex flex-wrap gap-2">
|
|
{(company.enrichment.data.customers as Record<string, unknown>[]).map(
|
|
(c, i) => {
|
|
const name = String(c.name ?? "");
|
|
if (!name) return null;
|
|
return (
|
|
<li key={i}>
|
|
<CompanyPillLink
|
|
name={name}
|
|
monitoredByName={monitoredByName}
|
|
returnTo={returnTo}
|
|
/>
|
|
</li>
|
|
);
|
|
},
|
|
)}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
|
|
<section className="rounded-lg border border-slate-200 bg-white p-5 text-sm">
|
|
<h2 className="font-medium text-slate-700">Products</h2>
|
|
{!Array.isArray(company.enrichment.data.products) ||
|
|
(company.enrichment.data.products as unknown[]).length === 0 ? (
|
|
<p className="mt-2 text-slate-500">None found.</p>
|
|
) : (
|
|
<ul className="mt-2 space-y-1.5">
|
|
{(company.enrichment.data.products as Record<string, unknown>[]).map(
|
|
(p, i) => (
|
|
<li key={i}>
|
|
<span className="text-slate-800">{String(p.name ?? "")}</span>
|
|
{p.description ? (
|
|
<span className="text-xs text-slate-500">
|
|
{" "}
|
|
— {String(p.description)}
|
|
</span>
|
|
) : null}
|
|
</li>
|
|
),
|
|
)}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
</div>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2">
|
|
<section className="rounded-lg border border-slate-200 bg-white p-5 text-sm">
|
|
<h2 className="font-medium text-slate-700">Recent updates</h2>
|
|
{!Array.isArray(company.enrichment.data.recent_updates) ||
|
|
(company.enrichment.data.recent_updates as unknown[]).length === 0 ? (
|
|
<p className="mt-2 text-slate-500">None found.</p>
|
|
) : (
|
|
<ul className="mt-2">
|
|
{(
|
|
company.enrichment.data.recent_updates as Record<string, unknown>[]
|
|
).map((u, i) => (
|
|
<li key={i}>
|
|
{i > 0 && <hr className="my-2 border-t border-slate-100 mx-3" />}
|
|
<p className="text-slate-700">{String(u.text ?? "")}</p>
|
|
{u.url ? (
|
|
<a
|
|
href={String(u.url)}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-xs text-brand-600 hover:underline"
|
|
>
|
|
{String(u.url)}
|
|
</a>
|
|
) : null}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
|
|
<section className="rounded-lg border border-slate-200 bg-white p-5 text-sm">
|
|
<h2 className="font-medium text-slate-700">
|
|
Competitors{" "}
|
|
<span className="text-xs font-normal text-slate-400">(API-suggested)</span>
|
|
</h2>
|
|
{!Array.isArray(company.enrichment.data.competitors) ||
|
|
(company.enrichment.data.competitors as unknown[]).length === 0 ? (
|
|
<p className="mt-2 text-slate-500">None found.</p>
|
|
) : (
|
|
<ul className="mt-2 flex flex-wrap gap-2">
|
|
{(
|
|
company.enrichment.data.competitors as Record<string, unknown>[]
|
|
).map((c, i) => {
|
|
const name = companyNameFromUrl(String(c.name ?? ""));
|
|
if (!name) return null;
|
|
return (
|
|
<li key={i}>
|
|
<CompanyPillLink
|
|
name={name}
|
|
subtitle={c.reason ? formatEnrichmentReason(String(c.reason)) : undefined}
|
|
monitoredByName={monitoredByName}
|
|
returnTo={returnTo}
|
|
/>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</section>
|
|
</div>
|
|
|
|
{company.enrichment.credits_spent !== null && (
|
|
<p className="text-xs text-slate-400">
|
|
{company.enrichment.credits_spent} NinjaPear Credits spent fetching this data
|
|
{company.enrichment.fetched_at
|
|
? ` on ${formatDateTime(company.enrichment.fetched_at)}`
|
|
: ""}
|
|
.
|
|
</p>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{tab === "Alerts" && (
|
|
<div>
|
|
{alertsLoading ? (
|
|
<p className="text-sm text-slate-500">Loading…</p>
|
|
) : !alerts || alerts.length === 0 ? (
|
|
<div className="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 for this company yet.</p>
|
|
</div>
|
|
) : (
|
|
<ul className="divide-y divide-slate-100 rounded-lg border border-slate-200 bg-white">
|
|
{alerts.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">
|
|
{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={() => markAlertRead.mutate(alert.id)}
|
|
className="focus-ring rounded-md p-2 text-slate-500 transition-colors duration-150 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={() => resolveAlert.mutate(alert.id)}
|
|
className="focus-ring rounded-md p-2 text-slate-500 transition-colors duration-150 hover:bg-slate-100"
|
|
>
|
|
<CheckCheck className="h-4 w-4" aria-hidden />
|
|
<span className="sr-only">Resolve</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{tab === "Configuration" && config && (
|
|
<div className="max-w-xl rounded-lg border border-slate-200 bg-white p-5">
|
|
<h2 className="text-sm font-medium text-slate-700">Monitoring configuration</h2>
|
|
<form
|
|
className="mt-4 space-y-4"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
const form = new FormData(e.currentTarget);
|
|
updateMonitor.mutate({
|
|
frequency_type: form.get("frequency_type") as never,
|
|
severity_threshold: form.get("severity_threshold") as never,
|
|
timezone: String(form.get("timezone")),
|
|
});
|
|
}}
|
|
>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700">Frequency</label>
|
|
<div className="mt-1">
|
|
<Select
|
|
name="frequency_type"
|
|
value={frequencyOverride ?? config.frequency_type}
|
|
onValueChange={setFrequencyOverride}
|
|
options={MONITORING_FREQUENCIES.map((f) => ({
|
|
value: f,
|
|
label: FREQUENCY_LABELS[f],
|
|
}))}
|
|
triggerClassName="w-full px-3 py-2 text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700">
|
|
Severity threshold
|
|
</label>
|
|
<div className="mt-1">
|
|
<Select
|
|
name="severity_threshold"
|
|
value={severityOverride ?? config.severity_threshold}
|
|
onValueChange={setSeverityOverride}
|
|
options={SEVERITY_LEVELS.map((s) => ({ value: s, label: SEVERITY_LABELS[s] }))}
|
|
triggerClassName="w-full px-3 py-2 text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-slate-700">Timezone</label>
|
|
<input
|
|
name="timezone"
|
|
defaultValue={config.timezone}
|
|
className="focus-ring mt-1 block w-full rounded-md border border-slate-300 px-3 py-2 text-sm shadow-sm"
|
|
/>
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
disabled={updateMonitor.isPending}
|
|
className="focus-ring rounded-md bg-brand-600 px-4 py-2 text-sm font-semibold text-white hover:bg-brand-700 disabled:opacity-60"
|
|
>
|
|
{updateMonitor.isPending ? "Saving…" : "Save changes"}
|
|
</button>
|
|
{updateMonitor.isSuccess && (
|
|
<p className="text-sm text-green-700">Configuration updated.</p>
|
|
)}
|
|
</form>
|
|
</div>
|
|
)}
|
|
|
|
{tab === "Configuration" && config && (
|
|
<div className="mt-4 max-w-xl rounded-lg border border-slate-200 bg-white p-5">
|
|
<h2 className="text-sm font-medium text-slate-700">Notifications</h2>
|
|
<p className="mt-1 text-xs text-slate-500">
|
|
Where alerts for this company get sent. Managing them here also updates the
|
|
Settings page, and vice versa.
|
|
</p>
|
|
<div className="mt-4 space-y-4">
|
|
<NotificationChannelBox
|
|
type="email"
|
|
label="Email"
|
|
placeholder="[email protected]"
|
|
companyId={company.id}
|
|
destination={emailDestination}
|
|
/>
|
|
<NotificationChannelBox
|
|
type="sms"
|
|
label="Phone"
|
|
placeholder="+15551234567"
|
|
companyId={company.id}
|
|
destination={smsDestination}
|
|
disabled={!systemStatus?.sms_enabled}
|
|
disabledReason="SMS delivery isn't enabled for this app yet."
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{tab === "Latest report" && (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<p className="text-xs text-slate-500">
|
|
{reports && reports.length > 0
|
|
? `${reports.length} report(s) generated for this company.`
|
|
: "No reports yet."}
|
|
</p>
|
|
<button
|
|
onClick={() => generateReport.mutate()}
|
|
disabled={generateReport.isPending}
|
|
className="focus-ring rounded-md border border-slate-300 px-3 py-1.5 text-xs font-medium text-slate-700 hover:bg-slate-50 disabled:opacity-60"
|
|
>
|
|
{generateReport.isPending ? "Generating…" : "Generate report now"}
|
|
</button>
|
|
</div>
|
|
|
|
{!runsLoading && (!runs || runs.length === 0) && (
|
|
<p className="rounded-md bg-amber-50 p-3 text-xs text-amber-800">
|
|
No monitoring run has collected any evidence yet, so a report generated now will
|
|
come back mostly empty by design — this app never fabricates findings without real
|
|
evidence. Click "Run now" above first, then generate the report.
|
|
</p>
|
|
)}
|
|
|
|
{reportsLoading ? (
|
|
<p className="text-sm text-slate-500">Loading…</p>
|
|
) : !reports || reports.length === 0 || !reports[0] ? (
|
|
<div className="rounded-lg border border-dashed border-slate-300 bg-white p-10 text-center text-sm text-slate-500">
|
|
No reports yet. Run a collection or click "Generate report now".
|
|
</div>
|
|
) : (
|
|
<div className="rounded-lg border border-slate-200 bg-white">
|
|
<div className="border-b border-slate-200 p-4">
|
|
<Link
|
|
href={`/reports/${reports[0].id}`}
|
|
className="text-sm font-medium text-brand-600 hover:text-brand-700"
|
|
>
|
|
View latest report ({reports[0].report_type},{" "}
|
|
{formatDateTime(reports[0].created_at)}) →
|
|
</Link>
|
|
<p className="mt-1 text-sm text-slate-600">{reports[0].executive_summary}</p>
|
|
</div>
|
|
{reports.length > 1 && (
|
|
<ul className="divide-y divide-slate-100">
|
|
{reports.slice(1).map((r) => (
|
|
<li key={r.id} className="p-3 text-sm">
|
|
<Link
|
|
href={`/reports/${r.id}`}
|
|
className="text-brand-600 hover:text-brand-700"
|
|
>
|
|
{r.title} — {r.report_type}
|
|
</Link>
|
|
<span className="ml-2 text-xs text-slate-400">
|
|
{formatDateTime(r.created_at)}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{tab === "Sources" && (
|
|
<div className="space-y-4">
|
|
<div className="rounded-lg border border-slate-200 bg-white p-5">
|
|
<h2 className="text-sm font-medium text-slate-700">Add a custom URL source</h2>
|
|
<p className="mt-1 text-xs text-slate-500">
|
|
Website, GitHub, SEC EDGAR, and careers-page sources are found automatically the
|
|
first time this company runs. Add any other public URL or RSS feed here to
|
|
monitor it too.
|
|
</p>
|
|
<form
|
|
className="mt-3 flex flex-wrap items-end gap-3"
|
|
onSubmit={(e) => {
|
|
e.preventDefault();
|
|
if (!newSourceName || !newSourceUrl) return;
|
|
createSource.mutate(
|
|
{ source_type: "custom_url", name: newSourceName, base_url: newSourceUrl },
|
|
{
|
|
onSuccess: () => {
|
|
setNewSourceName("");
|
|
setNewSourceUrl("");
|
|
},
|
|
},
|
|
);
|
|
}}
|
|
>
|
|
<div>
|
|
<label className="block text-xs font-medium text-slate-600">Name</label>
|
|
<input
|
|
value={newSourceName}
|
|
onChange={(e) => setNewSourceName(e.target.value)}
|
|
className="focus-ring mt-1 rounded-md border border-slate-300 px-3 py-2 text-sm"
|
|
placeholder="Pricing page"
|
|
/>
|
|
</div>
|
|
<div className="min-w-[200px] flex-1">
|
|
<label className="block text-xs font-medium text-slate-600">URL</label>
|
|
<input
|
|
value={newSourceUrl}
|
|
onChange={(e) => setNewSourceUrl(e.target.value)}
|
|
className="focus-ring mt-1 block w-full rounded-md border border-slate-300 px-3 py-2 text-sm"
|
|
placeholder="example.com/pricing"
|
|
/>
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
disabled={createSource.isPending}
|
|
className="focus-ring inline-flex items-center gap-1 rounded-md bg-brand-600 px-3 py-2 text-sm font-semibold text-white hover:bg-brand-700 disabled:opacity-60"
|
|
>
|
|
<Plus className="h-4 w-4" aria-hidden /> Add
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div className="rounded-lg border border-slate-200 bg-white">
|
|
{sourcesLoading ? (
|
|
<p className="p-5 text-sm text-slate-500">Loading…</p>
|
|
) : !sources || sources.length === 0 ? (
|
|
<p className="p-5 text-sm text-slate-500">
|
|
{runs && runs.length > 0
|
|
? "No sources configured yet."
|
|
: "Website, GitHub, and other sources are discovered automatically on the " +
|
|
'first monitoring run — click "Run now" above to get started, or add a ' +
|
|
"custom URL yourself above."}
|
|
</p>
|
|
) : (
|
|
<table className="w-full text-left text-sm">
|
|
<thead className="border-b border-slate-200 bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
|
|
<tr>
|
|
<th className="px-4 py-2">Name</th>
|
|
<th className="px-4 py-2">Type</th>
|
|
<th className="px-4 py-2">Status</th>
|
|
<th className="px-4 py-2">Last checked</th>
|
|
<th className="px-4 py-2">Check frequency</th>
|
|
<th className="px-4 py-2 text-right">Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100">
|
|
{sources.map((source) => {
|
|
const isTesting =
|
|
testSource.isPending && testSource.variables === source.id;
|
|
const isDeleting =
|
|
deleteSource.isPending && deleteSource.variables === source.id;
|
|
return (
|
|
<tr key={source.id}>
|
|
<td className="px-4 py-2">
|
|
{source.name}
|
|
{source.base_url && (
|
|
<div className="text-xs text-slate-500">{source.base_url}</div>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-2 text-slate-600">{source.source_type}</td>
|
|
<td className="px-4 py-2 text-slate-600">{source.status}</td>
|
|
<td className="px-4 py-2 text-slate-600">
|
|
{formatDateTime(source.last_checked)}
|
|
</td>
|
|
<td className="px-4 py-2">
|
|
<Select
|
|
ariaLabel={`Check frequency for ${source.name}`}
|
|
value={source.frequency_type ?? ""}
|
|
onValueChange={(value) =>
|
|
updateSource.mutate({
|
|
sourceId: source.id,
|
|
payload: {
|
|
frequency_type:
|
|
value === "" ? null : (value as MonitoringFrequency),
|
|
},
|
|
})
|
|
}
|
|
options={[
|
|
{ value: "", label: "Same as company" },
|
|
...MONITORING_FREQUENCIES.filter((f) => f !== "custom").map(
|
|
(f) => ({ value: f, label: FREQUENCY_LABELS[f] }),
|
|
),
|
|
]}
|
|
triggerClassName="w-full min-w-[140px] px-2 py-1.5 text-xs"
|
|
/>
|
|
</td>
|
|
<td className="px-4 py-2">
|
|
<div className="flex items-center justify-end gap-2">
|
|
<button
|
|
onClick={() =>
|
|
testSource.mutate(source.id, {
|
|
onSuccess: (result) =>
|
|
setTestResults((prev) => ({
|
|
...prev,
|
|
[source.id]: `${result.status} — ${result.documents_found} document(s)${result.error ? `: ${result.error}` : ""}`,
|
|
})),
|
|
})
|
|
}
|
|
disabled={isTesting}
|
|
title="Runs a real collection check on this source right now, outside its normal schedule"
|
|
className="focus-ring inline-flex items-center gap-1 rounded-md border border-slate-300 px-2 py-1 text-xs font-medium text-slate-700 transition-colors duration-150 hover:bg-slate-50 disabled:opacity-60"
|
|
>
|
|
{isTesting && (
|
|
<Loader2 className="h-3 w-3 animate-spin" aria-hidden />
|
|
)}
|
|
Test
|
|
</button>
|
|
<button
|
|
onClick={() => deleteSource.mutate(source.id)}
|
|
disabled={isDeleting}
|
|
className="focus-ring inline-flex items-center gap-1 rounded-md px-2 py-1 text-xs font-medium text-red-600 transition-colors duration-150 hover:bg-red-50 disabled:opacity-60"
|
|
>
|
|
{isDeleting && (
|
|
<Loader2 className="h-3 w-3 animate-spin" aria-hidden />
|
|
)}
|
|
Delete
|
|
</button>
|
|
</div>
|
|
{testResults[source.id] && (
|
|
<p className="mt-1 text-right text-xs text-slate-500">
|
|
{testResults[source.id]}
|
|
</p>
|
|
)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{tab === "Monitoring history" && (
|
|
<div className="rounded-lg border border-slate-200 bg-white">
|
|
{runsLoading ? (
|
|
<p className="p-5 text-sm text-slate-500">Loading…</p>
|
|
) : !runs || runs.length === 0 ? (
|
|
<p className="p-5 text-sm text-slate-500">
|
|
No monitoring runs yet. Click "Run now" above to start one.
|
|
</p>
|
|
) : (
|
|
<table className="w-full text-left text-sm">
|
|
<thead className="border-b border-slate-200 bg-slate-50 text-xs uppercase tracking-wide text-slate-500">
|
|
<tr>
|
|
<th className="px-4 py-2">Started</th>
|
|
<th className="px-4 py-2">Trigger</th>
|
|
<th className="px-4 py-2">Status</th>
|
|
<th className="px-4 py-2">Sources</th>
|
|
<th className="px-4 py-2">Items collected</th>
|
|
<th className="px-4 py-2">Notes</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-slate-100">
|
|
{runs.map((run) => (
|
|
<tr key={run.id}>
|
|
<td className="px-4 py-2 text-slate-600">
|
|
{formatDateTime(run.started_at ?? run.created_at)}
|
|
</td>
|
|
<td className="px-4 py-2 capitalize text-slate-600">{run.trigger_type}</td>
|
|
<td className="px-4 py-2">
|
|
<span
|
|
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium capitalize ${
|
|
RUN_STATUS_CLASSES[run.status] ?? "bg-slate-100 text-slate-700"
|
|
}`}
|
|
>
|
|
{run.status}
|
|
</span>
|
|
</td>
|
|
<td className="px-4 py-2 text-slate-600">
|
|
{run.sources_successful}/{run.sources_attempted} succeeded
|
|
</td>
|
|
<td className="px-4 py-2 text-slate-600">{run.items_collected}</td>
|
|
<td className="px-4 py-2 text-xs text-slate-500">
|
|
{run.error_summary ?? "—"}
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{tab === "Snapshots" && (
|
|
<div className="rounded-lg border border-slate-200 bg-white">
|
|
{snapshotsLoading ? (
|
|
<p className="p-5 text-sm text-slate-500">Loading…</p>
|
|
) : !snapshots || snapshots.length === 0 ? (
|
|
<p className="p-5 text-sm text-slate-500">
|
|
{runs && runs.length > 0
|
|
? "No snapshots recorded yet."
|
|
: 'No snapshots yet — snapshots are captured during monitoring runs. Click "Run now" above to get started.'}
|
|
</p>
|
|
) : (
|
|
<ul className="divide-y divide-slate-100">
|
|
{snapshots.map((snapshot) => {
|
|
const isExpanded = expandedSnapshots.has(snapshot.id);
|
|
const sourceName =
|
|
sources?.find((s) => s.id === snapshot.source_id)?.name ?? "Unknown source";
|
|
return (
|
|
<li key={snapshot.id}>
|
|
<button
|
|
onClick={() =>
|
|
setExpandedSnapshots((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(snapshot.id)) next.delete(snapshot.id);
|
|
else next.add(snapshot.id);
|
|
return next;
|
|
})
|
|
}
|
|
className="focus-ring flex w-full items-center justify-between gap-4 px-4 py-3 text-left hover:bg-slate-50"
|
|
>
|
|
<span className="flex min-w-0 items-center gap-2 text-sm">
|
|
{isExpanded ? (
|
|
<ChevronDown className="h-4 w-4 shrink-0 text-slate-400" aria-hidden />
|
|
) : (
|
|
<ChevronRight className="h-4 w-4 shrink-0 text-slate-400" aria-hidden />
|
|
)}
|
|
<span className="truncate font-medium text-slate-700">{sourceName}</span>
|
|
<span className="shrink-0 rounded-full bg-slate-100 px-2 py-0.5 text-xs capitalize text-slate-600">
|
|
{snapshot.snapshot_type}
|
|
</span>
|
|
</span>
|
|
<span className="shrink-0 text-xs text-slate-500">
|
|
{formatDateTime(snapshot.created_at)} · {charCount(snapshot.text_summary)}
|
|
</span>
|
|
</button>
|
|
{isExpanded && (
|
|
<div className="animate-fade-in space-y-3 border-t border-slate-100 bg-slate-50 px-4 py-3">
|
|
<div>
|
|
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
|
Hash
|
|
</p>
|
|
<p className="mt-1 break-all font-mono text-xs text-slate-600">
|
|
{snapshot.hash}
|
|
</p>
|
|
</div>
|
|
{snapshot.text_summary && (
|
|
<div>
|
|
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
|
Text summary
|
|
</p>
|
|
<p className="mt-1 max-h-64 overflow-y-auto whitespace-pre-wrap rounded-md bg-white p-3 text-xs text-slate-700">
|
|
{snapshot.text_summary}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{Object.keys(snapshot.structured_summary).length > 0 && (
|
|
<div>
|
|
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">
|
|
Structured summary
|
|
</p>
|
|
<pre className="mt-1 max-h-64 overflow-auto rounded-md bg-white p-3 text-xs text-slate-700">
|
|
{JSON.stringify(snapshot.structured_summary, null, 2)}
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|