import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useCallback, useEffect, useMemo, useState, type FormEvent, } from "react"; import { toast } from "sonner"; import { useMutation, useQuery } from "@tanstack/react-query"; import { useForm } from "@tanstack/react-form"; import { startAudit, getAuditStatus, getAuditResults, getAuditHistory, getCrawlProgress, deleteAudit, } from "@/serverFunctions/audit"; import { clearProjectPsiApiKey, getProjectPsiApiKey, saveProjectPsiApiKey, } from "@/serverFunctions/psi"; import { auditSearchSchema } from "@/types/schemas/audit"; import { ScanSearch, AlertCircle, CheckCircle, Trash2, MoreHorizontal, ExternalLink, Loader2, Download, ChevronDown, Settings, } from "lucide-react"; const SUPPORT_URL = "https://everyapp.dev/support"; export const Route = createFileRoute<"/p/$projectId/audit/">( "/p/$projectId/audit/", )({ validateSearch: auditSearchSchema, component: SiteAuditPage, }); function extractPathname(url: string): string { try { return new URL(url).pathname; } catch { return url; } } function extractHostname(url: string): string { try { return new URL(url).hostname; } catch { return url; } } function formatDate(dateStr: string): string { return new Date(dateStr).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric", }); } function formatStartedAt(dateStr: string): string { return new Date(dateStr).toLocaleString("en-US", { month: "short", day: "numeric", hour: "numeric", minute: "2-digit", }); } function SiteAuditPage() { const { projectId } = Route.useParams(); const { auditId, tab } = Route.useSearch(); const navigate = useNavigate({ from: Route.fullPath }); const setSearchParams = useCallback( (updates: Record) => { void navigate({ search: (prev) => ({ ...prev, ...updates }), replace: true, }); }, [navigate], ); if (auditId) { return ( setSearchParams({ auditId: undefined })} /> ); } return ( setSearchParams({ auditId: id })} /> ); } function LaunchView({ projectId, onAuditStarted, }: { projectId: string; onAuditStarted: (auditId: string) => void; }) { type LaunchFormValues = { url: string; maxPagesInput: string; runPsi: boolean; psiMode: "auto" | "all"; }; const defaultLaunchValues: LaunchFormValues = { url: "", maxPagesInput: "50", runPsi: false, psiMode: "auto", }; const minPages = 10; const maxPagesLimit = 10_000; const launchForm = useForm({ defaultValues: defaultLaunchValues, }); const settingsForm = useForm({ defaultValues: { psiApiKey: "", }, }); const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [showPsiKey, setShowPsiKey] = useState(false); const [urlError, setUrlError] = useState(null); const [psiRequirementError, setPsiRequirementError] = useState( null, ); const [startError, setStartError] = useState(null); const [settingsError, setSettingsError] = useState(null); const startMutation = useMutation({ mutationFn: (data: { projectId: string; startUrl: string; maxPages: number; psiStrategy: "auto" | "all" | "none"; psiApiKey?: string; }) => startAudit({ data }), }); const historyQuery = useQuery({ queryKey: ["audit-history", projectId], queryFn: () => getAuditHistory({ data: { projectId } }), }); const deleteMutation = useMutation({ mutationFn: (auditId: string) => deleteAudit({ data: { auditId } }), onSuccess: () => { void historyQuery.refetch(); toast.success("Audit deleted"); }, }); const keyQuery = useQuery({ queryKey: ["projectPsiApiKey", projectId], // PSI key is non-billing and used to prevent API abuse; this read-back is // intentional to keep setup simple for self-host users. queryFn: () => getProjectPsiApiKey({ data: { projectId } }), }); useEffect(() => { if (keyQuery.data?.apiKey) { settingsForm.setFieldValue("psiApiKey", keyQuery.data.apiKey); } }, [keyQuery.data?.apiKey, settingsForm]); const saveKeyMutation = useMutation({ mutationFn: (apiKey: string) => saveProjectPsiApiKey({ data: { projectId, apiKey } }), onSuccess: async () => { toast.success("PSI API key saved for this project"); await keyQuery.refetch(); }, }); const clearKeyMutation = useMutation({ mutationFn: () => clearProjectPsiApiKey({ data: { projectId } }), onSuccess: async () => { settingsForm.setFieldValue("psiApiKey", ""); toast.success("PSI API key cleared"); await keyQuery.refetch(); }, }); const applyMaxPages = (value: number) => { const safeValue = Number.isFinite(value) ? Math.max(minPages, Math.min(maxPagesLimit, Math.round(value))) : minPages; launchForm.setFieldValue("maxPagesInput", String(safeValue)); return safeValue; }; const commitMaxPagesInput = () => { const maxPagesInput = launchForm.state.values.maxPagesInput; if (!maxPagesInput) { return applyMaxPages(minPages); } const parsed = Number.parseInt(maxPagesInput, 10); return applyMaxPages(parsed); }; const handleStart = () => { const launchValues = launchForm.state.values; const settingsValues = settingsForm.state.values; const effectiveMaxPages = commitMaxPagesInput(); setStartError(null); if (!launchValues.url.trim()) { setUrlError("Please enter a URL."); return; } setUrlError(null); if (launchValues.runPsi && !settingsValues.psiApiKey.trim()) { setPsiRequirementError( "Set a Google PageSpeed Insights API key before running PSI checks.", ); setIsSettingsOpen(true); return; } setPsiRequirementError(null); if (effectiveMaxPages > 500) { const confirmed = window.confirm( `You are about to crawl ${effectiveMaxPages.toLocaleString()} pages. This is okay, but it may take a while. Continue?`, ); if (!confirmed) return; } startMutation.mutate( { projectId, startUrl: launchValues.url, maxPages: effectiveMaxPages, psiStrategy: launchValues.runPsi ? launchValues.psiMode : "none", psiApiKey: launchValues.runPsi ? settingsValues.psiApiKey || undefined : undefined, }, { onSuccess: (result) => { setStartError(null); toast.success("Audit started!"); onAuditStarted(result.auditId); }, onError: (error) => { setStartError( error instanceof Error ? error.message : "Failed to start audit", ); }, }, ); }; const handleStartSubmit = (event: FormEvent) => { event.preventDefault(); handleStart(); }; const history = historyQuery.data ?? []; const handleRunPsiToggle = (checked: boolean) => { const psiApiKey = settingsForm.state.values.psiApiKey; if (!checked) { setPsiRequirementError(null); launchForm.setFieldValue("runPsi", false); return; } if (!psiApiKey.trim()) { setIsSettingsOpen(true); return; } launchForm.setFieldValue("runPsi", true); }; return (

Site Audit

Start New Audit

Max pages {(field) => ( { const next = e.target.value; if (!/^\d*$/.test(next)) return; field.handleChange(next); }} onBlur={commitMaxPagesInput} /> )}

Enter any value from {minPages} to {maxPagesLimit}.

state.values.runPsi} > {(runPsi) => runPsi ? (
PSI mode {(field) => ( )} state.values.psiApiKey} > {(psiApiKey) => ( {psiApiKey.trim() ? "PSI key saved" : "PSI key required"} )}
) : null }
{urlError ? (

{urlError}

) : null} {psiRequirementError ? (
{psiRequirementError}
) : null} {startError ? (
{startError}
) : null}
{isSettingsOpen && (

Audit Settings

{(field) => ( { field.handleChange(e.target.value); if (settingsError) setSettingsError(null); if (psiRequirementError) setPsiRequirementError(null); }} /> )}

Stored on this project and reused by PSI and Site Audit. Required to run PSI checks in audits.

Need a PSI key?

  1. Open{" "} PageSpeed Insights getting started {" "} and click "Get a key".
  2. Create any Google Cloud project (for example: Open SEO).
  3. Paste the key here and save.
{settingsError ? (

{settingsError}

) : null}
)} {history.length > 0 && (

Previous Audits

{history.map((audit) => ( ))}
Date URL Status Pages PSI
{formatDate(audit.startedAt)} {audit.startUrl} {audit.pagesTotal || audit.pagesCrawled} {audit.ranPsi ? ( Yes ) : null}
)} {history.length === 0 && !historyQuery.isLoading && (

No audits yet

)}
); } function AuditDetail({ projectId, auditId, tab, setSearchParams, onBack, }: { projectId: string; auditId: string; tab: string; setSearchParams: (updates: Record) => void; onBack: () => void; }) { const statusQuery = useQuery({ queryKey: ["audit-status", auditId], queryFn: () => getAuditStatus({ data: { auditId } }), refetchInterval: (query) => { const data = query.state.data; return data?.status === "running" ? 3000 : false; }, }); const isComplete = statusQuery.data?.status === "completed"; const isFailed = statusQuery.data?.status === "failed"; const isRunning = statusQuery.data?.status === "running"; const resultsQuery = useQuery({ queryKey: ["audit-results", auditId], queryFn: () => getAuditResults({ data: { auditId } }), enabled: isComplete, }); if (statusQuery.isLoading) { return (
); } const status = statusQuery.data; const showSupportCta = isFailed || (isComplete && status && status.pagesCrawled <= 1); return (

Site Audit

{status?.status !== "running" && status && ( )}
{status && (

{extractHostname(status.startUrl)} · Started{" "} {formatStartedAt(status.startedAt)}

)}
{isRunning && status && ( )} {showSupportCta && (

Site audit couldn't fully crawl this website.

This is often caused by anti-bot or firewall settings. Reach out at{" "} everyapp.dev/support {" "} and we'll help configure auditing for your site.

)} {isComplete && resultsQuery.data && ( )}
); } function ProgressCard({ auditId, status, }: { auditId: string; status: { pagesCrawled: number; pagesTotal: number; psiTotal: number; psiCompleted: number; psiFailed: number; currentPhase: string | null; }; }) { const crawlProgress = status.pagesTotal > 0 ? Math.round((status.pagesCrawled / status.pagesTotal) * 100) : 0; const psiDone = status.psiCompleted + status.psiFailed; const psiProgress = status.psiTotal > 0 ? Math.round((psiDone / status.psiTotal) * 100) : 0; const isPsiPhase = status.currentPhase === "psi"; const phaseLabel = status.currentPhase === "discovery" ? "Discovery" : status.currentPhase === "crawling" ? "Crawling" : status.currentPhase === "psi" ? "PSI" : status.currentPhase === "finalizing" ? "Finalizing" : (status.currentPhase ?? "Running"); const progress = isPsiPhase ? psiProgress : crawlProgress; const crawlProgressQuery = useQuery({ queryKey: ["audit-crawl-progress", auditId], queryFn: () => getCrawlProgress({ data: { auditId } }), refetchInterval: 1500, }); const crawledUrls = crawlProgressQuery.data ?? []; return (

{isPsiPhase ? "Running PSI checks" : "Crawling pages"}

{phaseLabel}
{isPsiPhase ? ( {psiDone} / {status.psiTotal} checks {status.psiFailed > 0 ? ` (${status.psiFailed} failed)` : ""} ) : ( {status.pagesCrawled} / {status.pagesTotal} pages )} {progress}%
{crawledUrls.length > 0 && (

Crawled Pages ({crawledUrls.length})

Updated {new Date(crawledUrls[0].crawledAt).toLocaleTimeString()}

{crawledUrls.map((entry, i) => { const pathname = extractPathname(entry.url); return (
{pathname}
{entry.title && ( {entry.title} )}
); })}
)}
); } type AuditResultsData = Awaited>; function ResultsView({ projectId, data, tab, setSearchParams, }: { projectId: string; data: AuditResultsData; tab: string; setSearchParams: (updates: Record) => void; }) { const { audit, pages, psi } = data; const hasPerformanceTab = psi.length > 0; const activeTab = hasPerformanceTab ? tab : "pages"; const averageResponseMs = useMemo(() => { if (pages.length === 0) return 0; const total = pages.reduce( (sum, page) => sum + (page.responseTimeMs ?? 0), 0, ); return Math.round(total / pages.length); }, [pages]); const psiSummary = useMemo(() => { const failed = psi.filter((row) => !!row.errorMessage).length; const successful = psi.filter((row) => !row.errorMessage); const averageScore = ( rows: typeof successful, key: "performanceScore" | "seoScore" | "accessibilityScore", ) => { const values = rows .map((row) => row[key]) .filter((value): value is number => value != null); if (values.length === 0) return null; const total = values.reduce((sum, value) => sum + value, 0); return Math.round(total / values.length); }; return { failed, avgPerformance: averageScore(successful, "performanceScore"), avgSeo: averageScore(successful, "seoScore"), avgAccessibility: averageScore(successful, "accessibilityScore"), }; }, [psi]); return ( <>
{psi.length > 0 && ( <> = 90 ? "text-success" : psiSummary.avgPerformance >= 50 ? "text-warning" : "text-error" } /> = 90 ? "text-success" : psiSummary.avgSeo >= 50 ? "text-warning" : "text-error" } /> = 90 ? "text-success" : psiSummary.avgAccessibility >= 50 ? "text-warning" : "text-error" } /> 0 ? "text-error" : "text-success"} /> )}
{hasPerformanceTab ? (
) : (

Pages ({pages.length})

)} { if (activeTab === "performance") { exportPerformance(psi, pages, format); } else { exportPages(pages, format); } }} />
{activeTab === "pages" && (
{pages.map((page) => ( ))}
URL Status Title H1 Words Images Speed
{extractPathname(page.url)} {page.title || ( missing )} {page.h1Count} {page.wordCount} {page.imagesMissingAlt > 0 ? ( {page.imagesMissingAlt}/{page.imagesTotal} ) : ( page.imagesTotal )} {page.responseTimeMs ? `${page.responseTimeMs}ms` : "-"}
)} {activeTab === "performance" && psi.length > 0 && (
{psi.map((result) => { const page = pages.find((p) => p.id === result.pageId); const isFailed = !!result.errorMessage; return ( ); })}
URL Device Status Perf A11y SEO LCP CLS INP TTFB Issues
{page ? extractPathname(page.url) : "-"} {result.strategy} {isFailed ? ( failed ) : ( ok )} {result.lcpMs ? `${(result.lcpMs / 1000).toFixed(1)}s` : "-"} {result.cls != null ? result.cls.toFixed(3) : "-"} {result.inpMs ? `${Math.round(result.inpMs)}ms` : "-"} {result.ttfbMs ? `${Math.round(result.ttfbMs)}ms` : "-"} {result.r2Key ? ( View issues ) : ( - )}
)}
); } function StatusBadge({ status }: { status: string }) { if (status === "running") { return ( Running ); } if (status === "completed") { return ( Done ); } return ( Failed ); } function HttpStatusBadge({ code }: { code: number | null }) { if (!code) return -; if (code >= 200 && code < 300) return {code}; if (code >= 300 && code < 400) return {code}; return {code}; } function PsiScoreBadge({ score }: { score: number | null }) { if (score == null) { return -; } const color = score >= 90 ? "text-success" : score >= 50 ? "text-warning" : "text-error"; return {score}; } function StatCard({ label, value, className = "", }: { label: string; value: string; className?: string; }) { return (

{label}

{value}

); } function csvEscape( value: string | number | boolean | null | undefined, ): string { if (value == null) return ""; const text = String(value).replace(/"/g, '""'); return `"${text}"`; } function downloadFile(content: string, filename: string, mime: string) { const blob = new Blob([content], { type: `${mime};charset=utf-8;` }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = filename; link.click(); URL.revokeObjectURL(url); } function exportPages(pages: AuditResultsData["pages"], format: "csv" | "json") { const rows = pages.map((p) => ({ url: p.url, statusCode: p.statusCode, title: p.title ?? "", h1Count: p.h1Count, wordCount: p.wordCount, imagesTotal: p.imagesTotal, imagesMissingAlt: p.imagesMissingAlt, responseTimeMs: p.responseTimeMs, })); if (format === "json") { downloadFile( JSON.stringify(rows, null, 2), "audit-pages.json", "application/json", ); return; } const headers = [ "URL", "Status", "Title", "H1", "Words", "Images", "Missing Alt", "Response Time (ms)", ]; const lines = rows.map((r) => [ r.url, r.statusCode, r.title, r.h1Count, r.wordCount, r.imagesTotal, r.imagesMissingAlt, r.responseTimeMs, ] .map(csvEscape) .join(","), ); downloadFile( [headers.map(csvEscape).join(","), ...lines].join("\n"), "audit-pages.csv", "text/csv", ); } function exportPerformance( psi: AuditResultsData["psi"], pages: AuditResultsData["pages"], format: "csv" | "json", ) { const rows = psi.map((r) => { const page = pages.find((p) => p.id === r.pageId); return { url: page?.url ?? "", strategy: r.strategy, performance: r.performanceScore, accessibility: r.accessibilityScore, seo: r.seoScore, lcpMs: r.lcpMs, cls: r.cls, inpMs: r.inpMs, ttfbMs: r.ttfbMs, }; }); if (format === "json") { downloadFile( JSON.stringify(rows, null, 2), "audit-performance.json", "application/json", ); return; } const headers = [ "URL", "Device", "Performance", "Accessibility", "SEO", "LCP (ms)", "CLS", "INP (ms)", "TTFB (ms)", ]; const lines = rows.map((r) => [ r.url, r.strategy, r.performance, r.accessibility, r.seo, r.lcpMs, r.cls, r.inpMs, r.ttfbMs, ] .map(csvEscape) .join(","), ); downloadFile( [headers.map(csvEscape).join(","), ...lines].join("\n"), "audit-performance.csv", "text/csv", ); } function ExportDropdown({ onExport, }: { onExport: (format: "csv" | "json") => void; }) { return (
Export
); }