refactor: move lighthouse audits to dataforseo (#43)

* refactor: move lighthouse audits to dataforseo

* chore: remove obsolete audit settings modal

* refactor: rename psi flows to lighthouse

* save

* refactor: simplify audit lighthouse storage flow

* fix: separate lighthouse metrics from actionable audits

* refactor: remove redundant audit project inputs

* feat: redesign lighthouse issues screen with score gauges and table layout

Replace flat score cards with circular SVG gauges, condense metrics into
a compact grid, and switch issue list from cards to an expandable table
with fixed column widths.

* test: harden lighthouse regression coverage

* fix: restore project-scoped audit inputs

* refactor: simplify lighthouse payload handling

* refactor: inline lighthouse server handlers

* refactor: share audit workflow types

* refactor: simplify lighthouse payload flows

* save

* refactor: drop project pagespeed api key

* fix: restore lighthouse issues loading with resilient project context

* fix: restore audit issues back navigation

* refactor: simplify project context and lighthouse error handling

* fix: tolerate DataForSEO lighthouse payload drift

* refactor: route audit lighthouse through dataforseo client
This commit is contained in:
Ben Senescu 2026-03-25 15:16:05 -04:00
parent 739b3f0b6a
commit 638f5a6602
59 changed files with 5726 additions and 1858 deletions

3
.gitignore vendored
View File

@ -32,3 +32,6 @@ dist/
# Localflare generated files # Localflare generated files
.localflare/ .localflare/
# Local Claude config
.claude/

View File

@ -243,7 +243,7 @@ That means you can try OpenSEO for free with the starter credit, then decide if/
- DataForSEO Labs pricing: https://dataforseo.com/pricing/dataforseo-labs/dataforseo-google-api - DataForSEO Labs pricing: https://dataforseo.com/pricing/dataforseo-labs/dataforseo-google-api
- DataForSEO Backlinks pricing: https://dataforseo.com/pricing/backlinks/backlinks - DataForSEO Backlinks pricing: https://dataforseo.com/pricing/backlinks/backlinks
- Google PageSpeed Insights API docs: https://developers.google.com/speed/docs/insights/v5/get-started - DataForSEO Lighthouse API docs: https://docs.dataforseo.com/v3/on_page/lighthouse/overview/
### 1) Site audit ### 1) Site audit

View File

@ -0,0 +1,30 @@
ALTER TABLE `audit_psi_results` RENAME TO `audit_lighthouse_results`;--> statement-breakpoint
ALTER TABLE `audits` RENAME COLUMN "psi_total" TO "lighthouse_total";--> statement-breakpoint
ALTER TABLE `audits` RENAME COLUMN "psi_completed" TO "lighthouse_completed";--> statement-breakpoint
ALTER TABLE `audits` RENAME COLUMN "psi_failed" TO "lighthouse_failed";--> statement-breakpoint
PRAGMA foreign_keys=OFF;--> statement-breakpoint
CREATE TABLE `__new_audit_lighthouse_results` (
`id` text PRIMARY KEY NOT NULL,
`audit_id` text NOT NULL,
`page_id` text NOT NULL,
`strategy` text NOT NULL,
`performance_score` integer,
`accessibility_score` integer,
`best_practices_score` integer,
`seo_score` integer,
`lcp_ms` real,
`cls` real,
`inp_ms` real,
`ttfb_ms` real,
`error_message` text,
`r2_key` text,
`payload_size_bytes` integer,
FOREIGN KEY (`audit_id`) REFERENCES `audits`(`id`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (`page_id`) REFERENCES `audit_pages`(`id`) ON UPDATE no action ON DELETE cascade
);
--> statement-breakpoint
INSERT INTO `__new_audit_lighthouse_results`("id", "audit_id", "page_id", "strategy", "performance_score", "accessibility_score", "best_practices_score", "seo_score", "lcp_ms", "cls", "inp_ms", "ttfb_ms", "error_message", "r2_key", "payload_size_bytes") SELECT "id", "audit_id", "page_id", "strategy", "performance_score", "accessibility_score", "best_practices_score", "seo_score", "lcp_ms", "cls", "inp_ms", "ttfb_ms", "error_message", "r2_key", "payload_size_bytes" FROM `audit_lighthouse_results`;--> statement-breakpoint
DROP TABLE `audit_lighthouse_results`;--> statement-breakpoint
ALTER TABLE `__new_audit_lighthouse_results` RENAME TO `audit_lighthouse_results`;--> statement-breakpoint
PRAGMA foreign_keys=ON;--> statement-breakpoint
CREATE INDEX `audit_lighthouse_results_audit_id_idx` ON `audit_lighthouse_results` (`audit_id`);

View File

@ -0,0 +1 @@
ALTER TABLE `projects` DROP COLUMN `pagespeed_api_key`;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -36,6 +36,20 @@
"when": 1773935379368, "when": 1773935379368,
"tag": "0004_faithful_sunset_bain", "tag": "0004_faithful_sunset_bain",
"breakpoints": true "breakpoints": true
},
{
"idx": 5,
"version": "6",
"when": 1773965298920,
"tag": "0005_low_red_hulk",
"breakpoints": true
},
{
"idx": 6,
"version": "6",
"when": 1774320825595,
"tag": "0006_magical_alex_wilder",
"breakpoints": true
} }
] ]
} }

View File

@ -38,7 +38,7 @@ export function AuditHistorySection({
<th>URL</th> <th>URL</th>
<th>Status</th> <th>Status</th>
<th>Pages</th> <th>Pages</th>
<th>PSI</th> <th>Lighthouse</th>
<th></th> <th></th>
</tr> </tr>
</thead> </thead>
@ -54,7 +54,7 @@ export function AuditHistorySection({
</td> </td>
<td>{audit.pagesTotal || audit.pagesCrawled}</td> <td>{audit.pagesTotal || audit.pagesCrawled}</td>
<td> <td>
{audit.ranPsi ? ( {audit.ranLighthouse ? (
<span className="badge badge-ghost badge-xs">Yes</span> <span className="badge badge-ghost badge-xs">Yes</span>
) : null} ) : null}
</td> </td>

View File

@ -1,48 +1,33 @@
import type { FormEvent } from "react"; import type { FormEvent } from "react";
import { Loader2, Settings } from "lucide-react"; import { Loader2 } from "lucide-react";
import { import {
MAX_PAGES_LIMIT, MAX_PAGES_LIMIT,
MIN_PAGES, MIN_PAGES,
type LaunchFormApi, type LaunchFormApi,
type LaunchState, type LaunchState,
type SettingsFormApi,
} from "@/client/features/audit/launch/types"; } from "@/client/features/audit/launch/types";
export function LaunchFormCard({ export function LaunchFormCard({
launchForm, launchForm,
settingsForm,
state, state,
setState, setState,
isPending, isPending,
onSubmit, onSubmit,
onOpenSettings, onRunLighthouseToggle,
onRunPsiToggle,
commitMaxPagesInput, commitMaxPagesInput,
}: { }: {
launchForm: LaunchFormApi; launchForm: LaunchFormApi;
settingsForm: SettingsFormApi;
state: LaunchState; state: LaunchState;
setState: React.Dispatch<React.SetStateAction<LaunchState>>; setState: React.Dispatch<React.SetStateAction<LaunchState>>;
isPending: boolean; isPending: boolean;
onSubmit: (event: FormEvent) => void; onSubmit: (event: FormEvent) => void;
onOpenSettings: () => void; onRunLighthouseToggle: (checked: boolean) => void;
onRunPsiToggle: (checked: boolean) => void;
commitMaxPagesInput: () => number; commitMaxPagesInput: () => number;
}) { }) {
return ( return (
<div className="card bg-base-100 border border-base-300"> <div className="card bg-base-100 border border-base-300">
<div className="card-body gap-4"> <div className="card-body gap-4">
<div className="flex items-center justify-between">
<h2 className="card-title text-base">Start New Audit</h2> <h2 className="card-title text-base">Start New Audit</h2>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={onOpenSettings}
>
<Settings className="size-4" />
Settings
</button>
</div>
<form <form
className="grid grid-cols-1 gap-3 lg:grid-cols-12 lg:items-center" className="grid grid-cols-1 gap-3 lg:grid-cols-12 lg:items-center"
@ -85,10 +70,9 @@ export function LaunchFormCard({
launchForm={launchForm} launchForm={launchForm}
commitMaxPagesInput={commitMaxPagesInput} commitMaxPagesInput={commitMaxPagesInput}
/> />
<PsiOptions <LighthouseOptions
launchForm={launchForm} launchForm={launchForm}
settingsForm={settingsForm} onRunLighthouseToggle={onRunLighthouseToggle}
onRunPsiToggle={onRunPsiToggle}
/> />
</div> </div>
</form> </form>
@ -138,42 +122,42 @@ function LaunchOptions({
); );
} }
function PsiOptions({ function LighthouseOptions({
launchForm, launchForm,
settingsForm, onRunLighthouseToggle,
onRunPsiToggle,
}: { }: {
launchForm: LaunchFormApi; launchForm: LaunchFormApi;
settingsForm: SettingsFormApi; onRunLighthouseToggle: (checked: boolean) => void;
onRunPsiToggle: (checked: boolean) => void;
}) { }) {
return ( return (
<div className="rounded-lg border border-base-300 bg-base-200/20 p-3 space-y-2"> <div className="rounded-lg border border-base-300 bg-base-200/20 p-3 space-y-2">
<label className="label cursor-pointer justify-start gap-2 p-0"> <label className="label cursor-pointer justify-start gap-2 p-0">
<launchForm.Field name="runPsi"> <launchForm.Field name="runLighthouse">
{(field) => ( {(field) => (
<input <input
type="checkbox" type="checkbox"
className="toggle toggle-sm toggle-primary" className="toggle toggle-sm toggle-primary"
checked={Boolean(field.state.value)} checked={Boolean(field.state.value)}
onChange={(event) => onRunPsiToggle(event.target.checked)} onChange={(event) => onRunLighthouseToggle(event.target.checked)}
/> />
)} )}
</launchForm.Field> </launchForm.Field>
<span <span
className="text-sm font-medium text-base-content/80" className="text-sm font-medium text-base-content/80"
title="Run Google PageSpeed Insights checks during this audit" title="Run Lighthouse checks through DataForSEO during this audit"
> >
Include PSI checks Include Lighthouse checks
</span> </span>
</label> </label>
<launchForm.Subscribe selector={(snapshot) => snapshot.values.runPsi}> <launchForm.Subscribe
{(runPsi) => selector={(snapshot) => snapshot.values.runLighthouse}
runPsi ? ( >
{(runLighthouse) =>
runLighthouse ? (
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<span className="text-xs text-base-content/60">PSI mode</span> <span className="text-xs text-base-content/60">Audit scope</span>
<launchForm.Field name="psiMode"> <launchForm.Field name="lighthouseMode">
{(field) => ( {(field) => (
<select <select
className="select select-bordered select-xs" className="select select-bordered select-xs"
@ -189,17 +173,9 @@ function PsiOptions({
</select> </select>
)} )}
</launchForm.Field> </launchForm.Field>
<settingsForm.Subscribe <span className="text-xs text-base-content/50">
selector={(snapshot) => snapshot.values.psiApiKey} Powered by DataForSEO Lighthouse
>
{(psiApiKey) => (
<span
className={`text-xs ${psiApiKey.trim() ? "text-success/80" : "text-warning"}`}
>
{psiApiKey.trim() ? "PSI key saved" : "PSI key required"}
</span> </span>
)}
</settingsForm.Subscribe>
</div> </div>
) : null ) : null
} }
@ -214,11 +190,6 @@ function LaunchErrors({ state }: { state: LaunchState }) {
{state.urlError ? ( {state.urlError ? (
<p className="text-sm text-error">{state.urlError}</p> <p className="text-sm text-error">{state.urlError}</p>
) : null} ) : null}
{state.psiRequirementError ? (
<div className="alert alert-warning py-2">
<span className="text-sm">{state.psiRequirementError}</span>
</div>
) : null}
{state.startError ? ( {state.startError ? (
<div className="alert alert-error py-2"> <div className="alert alert-error py-2">
<span className="text-sm">{state.startError}</span> <span className="text-sm">{state.startError}</span>

View File

@ -1,6 +1,5 @@
import { AuditHistorySection } from "@/client/features/audit/launch/AuditHistorySection"; import { AuditHistorySection } from "@/client/features/audit/launch/AuditHistorySection";
import { LaunchFormCard } from "@/client/features/audit/launch/LaunchFormCard"; import { LaunchFormCard } from "@/client/features/audit/launch/LaunchFormCard";
import { SettingsModal } from "@/client/features/audit/launch/SettingsModal";
import { useLaunchController } from "@/client/features/audit/launch/useLaunchController"; import { useLaunchController } from "@/client/features/audit/launch/useLaunchController";
export function LaunchView({ export function LaunchView({
@ -19,26 +18,14 @@ export function LaunchView({
<LaunchFormCard <LaunchFormCard
launchForm={controller.launchForm} launchForm={controller.launchForm}
settingsForm={controller.settingsForm}
state={controller.state} state={controller.state}
setState={controller.setState} setState={controller.setState}
isPending={controller.startMutation.isPending} isPending={controller.startMutation.isPending}
onSubmit={controller.handleSubmit} onSubmit={controller.handleSubmit}
onOpenSettings={controller.openSettings} onRunLighthouseToggle={controller.onRunLighthouseToggle}
onRunPsiToggle={controller.onRunPsiToggle}
commitMaxPagesInput={controller.commitMaxPagesInput} commitMaxPagesInput={controller.commitMaxPagesInput}
/> />
{controller.state.isSettingsOpen && (
<SettingsModal
settingsForm={controller.settingsForm}
state={controller.state}
setState={controller.setState}
onClear={controller.clearPsiKey}
onSave={controller.saveSettings}
/>
)}
<AuditHistorySection <AuditHistorySection
history={controller.historyQuery.data ?? []} history={controller.historyQuery.data ?? []}
isLoading={controller.historyQuery.isLoading} isLoading={controller.historyQuery.isLoading}

View File

@ -1,128 +0,0 @@
import type {
LaunchState,
SettingsFormApi,
} from "@/client/features/audit/launch/types";
export function SettingsModal({
settingsForm,
state,
setState,
onClear,
onSave,
}: {
settingsForm: SettingsFormApi;
state: LaunchState;
setState: React.Dispatch<React.SetStateAction<LaunchState>>;
onClear: () => void;
onSave: () => void;
}) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/35 p-4">
<div className="card w-full max-w-lg bg-base-100 border border-base-300 shadow-xl">
<div className="card-body gap-4">
<div className="flex items-center justify-between">
<h3 className="card-title text-base">Audit Settings</h3>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() =>
setState((prev) => ({ ...prev, isSettingsOpen: false }))
}
>
Close
</button>
</div>
<div className="space-y-2">
<label className="text-sm text-base-content/70">
Google PageSpeed Insights API Key
</label>
<div className="flex gap-2">
<settingsForm.Field name="psiApiKey">
{(field) => (
<input
type={state.showPsiKey ? "text" : "password"}
className="input input-bordered flex-1"
placeholder="Google API key"
value={field.state.value}
onChange={(event) => {
field.handleChange(event.target.value);
if (state.settingsError || state.psiRequirementError) {
setState((prev) => ({
...prev,
settingsError: null,
psiRequirementError: null,
}));
}
}}
/>
)}
</settingsForm.Field>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() =>
setState((prev) => ({
...prev,
showPsiKey: !prev.showPsiKey,
}))
}
>
{state.showPsiKey ? "Hide" : "Show"}
</button>
</div>
<p className="text-xs text-base-content/50">
Stored on this project and reused by PSI and Site Audit. Required
to run PSI checks in audits.
</p>
<PsiKeyHelp />
{state.settingsError ? (
<p className="text-sm text-error">{state.settingsError}</p>
) : null}
</div>
<div className="flex items-center justify-between">
<button
type="button"
className="btn btn-ghost btn-sm text-error"
onClick={onClear}
>
Clear key
</button>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={onSave}
>
Save settings
</button>
</div>
</div>
</div>
</div>
);
}
function PsiKeyHelp() {
return (
<div className="rounded-md border border-base-300 bg-base-200/30 p-3 text-xs text-base-content/70 space-y-1.5">
<p className="font-medium text-base-content/80">Need a PSI key?</p>
<ol className="list-decimal list-inside space-y-1">
<li>
Open{" "}
<a
className="link link-primary"
href="https://developers.google.com/speed/docs/insights/v5/get-started"
target="_blank"
rel="noopener noreferrer"
>
PageSpeed Insights getting started
</a>{" "}
and click "Get a key".
</li>
<li>Create any Google Cloud project (for example: Open SEO).</li>
<li>Paste the key here and save.</li>
</ol>
</div>
);
}

View File

@ -1,12 +1,8 @@
import { useForm } from "@tanstack/react-form"; import { useForm } from "@tanstack/react-form";
export type LaunchState = { export type LaunchState = {
isSettingsOpen: boolean;
showPsiKey: boolean;
urlError: string | null; urlError: string | null;
psiRequirementError: string | null;
startError: string | null; startError: string | null;
settingsError: string | null;
}; };
export const MIN_PAGES = 10; export const MIN_PAGES = 10;
@ -17,15 +13,10 @@ export function useLaunchForm() {
defaultValues: { defaultValues: {
url: "", url: "",
maxPagesInput: "50", maxPagesInput: "50",
runPsi: false, runLighthouse: false,
psiMode: "auto" as "auto" | "all", lighthouseMode: "auto" as "auto" | "all",
}, },
}); });
} }
export function useSettingsForm() {
return useForm({ defaultValues: { psiApiKey: "" } });
}
export type LaunchFormApi = ReturnType<typeof useLaunchForm>; export type LaunchFormApi = ReturnType<typeof useLaunchForm>;
export type SettingsFormApi = ReturnType<typeof useSettingsForm>;

View File

@ -1,4 +1,4 @@
import { useEffect, useState, type FormEvent } from "react"; import { useState, type FormEvent } from "react";
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { import {
@ -6,16 +6,10 @@ import {
getAuditHistory, getAuditHistory,
startAudit, startAudit,
} from "@/serverFunctions/audit"; } from "@/serverFunctions/audit";
import {
clearProjectPsiApiKey,
getProjectPsiApiKey,
saveProjectPsiApiKey,
} from "@/serverFunctions/psi";
import { import {
MAX_PAGES_LIMIT, MAX_PAGES_LIMIT,
MIN_PAGES, MIN_PAGES,
useLaunchForm, useLaunchForm,
useSettingsForm,
type LaunchState, type LaunchState,
} from "@/client/features/audit/launch/types"; } from "@/client/features/audit/launch/types";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
@ -28,34 +22,20 @@ export function useLaunchController({
onAuditStarted: (auditId: string) => void; onAuditStarted: (auditId: string) => void;
}) { }) {
const launchForm = useLaunchForm(); const launchForm = useLaunchForm();
const settingsForm = useSettingsForm();
const [state, setState] = useState<LaunchState>({ const [state, setState] = useState<LaunchState>({
isSettingsOpen: false,
showPsiKey: false,
urlError: null, urlError: null,
psiRequirementError: null,
startError: null, startError: null,
settingsError: null,
}); });
const historyQuery = useQuery({ const historyQuery = useQuery({
queryKey: ["audit-history", projectId], queryKey: ["audit-history", projectId],
queryFn: () => getAuditHistory({ data: { projectId } }), queryFn: () => getAuditHistory({ data: { projectId } }),
}); });
const keyQuery = useQuery({ const { startMutation, deleteMutation } = useLaunchMutations({
queryKey: ["projectPsiApiKey", projectId],
queryFn: () => getProjectPsiApiKey({ data: { projectId } }),
});
const { startMutation, deleteMutation, saveKeyMutation, clearKeyMutation } =
useLaunchMutations({
projectId, projectId,
historyRefetch: historyQuery.refetch, historyRefetch: historyQuery.refetch,
keyRefetch: keyQuery.refetch,
clearPsiApiKeyField: () => settingsForm.setFieldValue("psiApiKey", ""),
}); });
useSyncPsiKeyField(keyQuery.data?.apiKey, settingsForm);
const applyMaxPages = (value: number) => { const applyMaxPages = (value: number) => {
const safeValue = Number.isFinite(value) const safeValue = Number.isFinite(value)
? Math.max(MIN_PAGES, Math.min(MAX_PAGES_LIMIT, Math.round(value))) ? Math.max(MIN_PAGES, Math.min(MAX_PAGES_LIMIT, Math.round(value)))
@ -72,20 +52,13 @@ export function useLaunchController({
const handleStart = () => { const handleStart = () => {
const launchValues = launchForm.state.values; const launchValues = launchForm.state.values;
const settingsValues = settingsForm.state.values;
const effectiveMaxPages = commitMaxPagesInput(); const effectiveMaxPages = commitMaxPagesInput();
setState((prev) => ({ ...prev, startError: null })); setState((prev) => ({ ...prev, startError: null }));
if (!launchValues.url.trim()) if (!launchValues.url.trim()) {
return setState((prev) => ({ ...prev, urlError: "Please enter a URL." })); return setState((prev) => ({ ...prev, urlError: "Please enter a URL." }));
if (launchValues.runPsi && !settingsValues.psiApiKey.trim()) {
return setState((prev) => ({
...prev,
psiRequirementError:
"Set a Google PageSpeed Insights API key before running PSI checks.",
isSettingsOpen: true,
}));
} }
if (effectiveMaxPages > 500) { if (effectiveMaxPages > 500) {
const confirmed = window.confirm( const confirmed = window.confirm(
`You are about to crawl ${effectiveMaxPages.toLocaleString()} pages. This is okay, but it may take a while. Continue?`, `You are about to crawl ${effectiveMaxPages.toLocaleString()} pages. This is okay, but it may take a while. Continue?`,
@ -98,19 +71,13 @@ export function useLaunchController({
projectId, projectId,
startUrl: launchValues.url, startUrl: launchValues.url,
maxPages: effectiveMaxPages, maxPages: effectiveMaxPages,
psiStrategy: launchValues.runPsi ? launchValues.psiMode : "none", lighthouseStrategy: launchValues.runLighthouse
psiApiKey: launchValues.runPsi ? launchValues.lighthouseMode
? settingsValues.psiApiKey || undefined : "none",
: undefined,
}, },
{ {
onSuccess: (result) => { onSuccess: (result) => {
setState((prev) => ({ setState({ urlError: null, startError: null });
...prev,
urlError: null,
psiRequirementError: null,
startError: null,
}));
toast.success("Audit started!"); toast.success("Audit started!");
onAuditStarted(result.auditId); onAuditStarted(result.auditId);
}, },
@ -126,7 +93,6 @@ export function useLaunchController({
return { return {
launchForm, launchForm,
settingsForm,
state, state,
setState, setState,
historyQuery, historyQuery,
@ -136,45 +102,25 @@ export function useLaunchController({
event.preventDefault(); event.preventDefault();
handleStart(); handleStart();
}, },
openSettings: () => setState((prev) => ({ ...prev, isSettingsOpen: true })), onRunLighthouseToggle: (checked: boolean) =>
onRunPsiToggle: (checked: boolean) => handleRunLighthouseToggle(checked, launchForm),
handleRunPsiToggle(checked, launchForm, settingsForm, setState),
saveSettings: () =>
handleSaveSettings(settingsForm, setState, saveKeyMutation.mutate),
clearPsiKey: () => clearKeyMutation.mutate(),
deleteAudit: (auditId: string) => deleteMutation.mutate(auditId), deleteAudit: (auditId: string) => deleteMutation.mutate(auditId),
}; };
} }
function useSyncPsiKeyField(
apiKey: string | null | undefined,
settingsForm: ReturnType<typeof useSettingsForm>,
) {
useEffect(() => {
if (apiKey) {
settingsForm.setFieldValue("psiApiKey", apiKey);
}
}, [apiKey, settingsForm]);
}
function useLaunchMutations({ function useLaunchMutations({
projectId, projectId,
historyRefetch, historyRefetch,
keyRefetch,
clearPsiApiKeyField,
}: { }: {
projectId: string; projectId: string;
historyRefetch: () => Promise<unknown>; historyRefetch: () => Promise<unknown>;
keyRefetch: () => Promise<unknown>;
clearPsiApiKeyField: () => void;
}) { }) {
const startMutation = useMutation({ const startMutation = useMutation({
mutationFn: (data: { mutationFn: (data: {
projectId: string; projectId: string;
startUrl: string; startUrl: string;
maxPages: number; maxPages: number;
psiStrategy: "auto" | "all" | "none"; lighthouseStrategy: "auto" | "all" | "none";
psiApiKey?: string;
}) => startAudit({ data }), }) => startAudit({ data }),
}); });
@ -187,67 +133,12 @@ function useLaunchMutations({
}, },
}); });
const saveKeyMutation = useMutation({ return { startMutation, deleteMutation };
mutationFn: (apiKey: string) =>
saveProjectPsiApiKey({ data: { projectId, apiKey } }),
onSuccess: async () => {
toast.success("PSI API key saved for this project");
await keyRefetch();
},
});
const clearKeyMutation = useMutation({
mutationFn: () => clearProjectPsiApiKey({ data: { projectId } }),
onSuccess: async () => {
clearPsiApiKeyField();
toast.success("PSI API key cleared");
await keyRefetch();
},
});
return { startMutation, deleteMutation, saveKeyMutation, clearKeyMutation };
} }
function handleRunPsiToggle( function handleRunLighthouseToggle(
checked: boolean, checked: boolean,
launchForm: ReturnType<typeof useLaunchForm>, launchForm: ReturnType<typeof useLaunchForm>,
settingsForm: ReturnType<typeof useSettingsForm>,
setState: React.Dispatch<React.SetStateAction<LaunchState>>,
) { ) {
if (!checked) { launchForm.setFieldValue("runLighthouse", checked);
setState((prev) => ({ ...prev, psiRequirementError: null }));
launchForm.setFieldValue("runPsi", false);
return;
}
if (!settingsForm.state.values.psiApiKey.trim()) {
setState((prev) => ({ ...prev, isSettingsOpen: true }));
return;
}
launchForm.setFieldValue("runPsi", true);
}
function handleSaveSettings(
settingsForm: ReturnType<typeof useSettingsForm>,
setState: React.Dispatch<React.SetStateAction<LaunchState>>,
save: (apiKey: string) => void,
) {
const trimmed = settingsForm.state.values.psiApiKey.trim();
if (!trimmed) {
setState((prev) => ({
...prev,
settingsError: "Please enter an API key.",
}));
return;
}
setState((prev) => ({
...prev,
settingsError: null,
psiRequirementError: null,
showPsiKey: false,
isSettingsOpen: false,
}));
save(trimmed);
} }

View File

@ -2,10 +2,35 @@ import { ChevronDown, Download, ExternalLink } from "lucide-react";
import { import {
extractPathname, extractPathname,
HttpStatusBadge, HttpStatusBadge,
PsiScoreBadge, LighthouseScoreBadge,
} from "@/client/features/audit/shared"; } from "@/client/features/audit/shared";
import type { AuditResultsData } from "@/client/features/audit/results/types"; import type { AuditResultsData } from "@/client/features/audit/results/types";
type LighthouseFailureFields = {
errorMessage: string | null;
performanceScore: number | null;
accessibilityScore: number | null;
bestPracticesScore: number | null;
seoScore: number | null;
};
function hasMissingLighthouseScores(row: LighthouseFailureFields) {
return (
row.performanceScore == null &&
row.accessibilityScore == null &&
row.bestPracticesScore == null &&
row.seoScore == null
);
}
export function isLighthouseFailure(row: LighthouseFailureFields) {
return !!row.errorMessage || hasMissingLighthouseScores(row);
}
function getLighthouseFailureMessage(row: LighthouseFailureFields) {
return row.errorMessage ?? "Lighthouse returned no category scores";
}
export function PagesTable({ pages }: { pages: AuditResultsData["pages"] }) { export function PagesTable({ pages }: { pages: AuditResultsData["pages"] }) {
return ( return (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
@ -22,7 +47,7 @@ export function PagesTable({ pages }: { pages: AuditResultsData["pages"] }) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{pages.map((page) => ( {pages.map((page: AuditResultsData["pages"][number]) => (
<tr key={page.id}> <tr key={page.id}>
<td className="max-w-[200px] truncate"> <td className="max-w-[200px] truncate">
<a <a
@ -66,12 +91,14 @@ export function PagesTable({ pages }: { pages: AuditResultsData["pages"] }) {
} }
export function PerformanceTable({ export function PerformanceTable({
auditId,
projectId, projectId,
psi, lighthouse,
pages, pages,
}: { }: {
auditId: string;
projectId: string; projectId: string;
psi: AuditResultsData["psi"]; lighthouse: AuditResultsData["lighthouse"];
pages: AuditResultsData["pages"]; pages: AuditResultsData["pages"];
}) { }) {
return ( return (
@ -93,12 +120,16 @@ export function PerformanceTable({
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{psi.map((result) => ( {lighthouse.map((result: AuditResultsData["lighthouse"][number]) => (
<PerformanceRow <PerformanceRow
key={result.id} key={result.id}
auditId={auditId}
projectId={projectId} projectId={projectId}
result={result} result={result}
page={pages.find((candidate) => candidate.id === result.pageId)} page={pages.find(
(candidate: AuditResultsData["pages"][number]) =>
candidate.id === result.pageId,
)}
/> />
))} ))}
</tbody> </tbody>
@ -108,15 +139,18 @@ export function PerformanceTable({
} }
function PerformanceRow({ function PerformanceRow({
auditId,
projectId, projectId,
result, result,
page, page,
}: { }: {
auditId: string;
projectId: string; projectId: string;
result: AuditResultsData["psi"][number]; result: AuditResultsData["lighthouse"][number];
page: AuditResultsData["pages"][number] | undefined; page: AuditResultsData["pages"][number] | undefined;
}) { }) {
const isFailed = !!result.errorMessage; const isFailed = isLighthouseFailure(result);
const failureMessage = getLighthouseFailureMessage(result);
return ( return (
<tr> <tr>
@ -128,7 +162,7 @@ function PerformanceRow({
{isFailed ? ( {isFailed ? (
<span <span
className="badge badge-error badge-outline text-xs" className="badge badge-error badge-outline text-xs"
title={result.errorMessage ?? "PSI check failed"} title={failureMessage}
> >
failed failed
</span> </span>
@ -137,13 +171,13 @@ function PerformanceRow({
)} )}
</td> </td>
<td> <td>
<PsiScoreBadge score={result.performanceScore} /> <LighthouseScoreBadge score={result.performanceScore} />
</td> </td>
<td> <td>
<PsiScoreBadge score={result.accessibilityScore} /> <LighthouseScoreBadge score={result.accessibilityScore} />
</td> </td>
<td> <td>
<PsiScoreBadge score={result.seoScore} /> <LighthouseScoreBadge score={result.seoScore} />
</td> </td>
<td className="text-xs"> <td className="text-xs">
{result.lcpMs ? `${(result.lcpMs / 1000).toFixed(1)}s` : "-"} {result.lcpMs ? `${(result.lcpMs / 1000).toFixed(1)}s` : "-"}
@ -158,10 +192,10 @@ function PerformanceRow({
{result.ttfbMs ? `${Math.round(result.ttfbMs)}ms` : "-"} {result.ttfbMs ? `${Math.round(result.ttfbMs)}ms` : "-"}
</td> </td>
<td> <td>
{result.r2Key ? ( {result.r2Key && !isFailed ? (
<a <a
className="btn btn-primary btn-xs" className="btn btn-primary btn-xs"
href={`/p/${projectId}/audit/issues/${result.id}?category=performance`} href={`/p/${projectId}/audit/issues/${result.id}?auditId=${auditId}&category=performance`}
> >
View issues View issues
</a> </a>

View File

@ -7,6 +7,7 @@ import {
import type { AuditResultsData } from "@/client/features/audit/results/types"; import type { AuditResultsData } from "@/client/features/audit/results/types";
import { import {
ExportDropdown, ExportDropdown,
isLighthouseFailure,
PagesTable, PagesTable,
PerformanceTable, PerformanceTable,
} from "@/client/features/audit/results/ResultsTables"; } from "@/client/features/audit/results/ResultsTables";
@ -24,32 +25,32 @@ export function ResultsView({
tab: string; tab: string;
setSearchParams: SearchSetter; setSearchParams: SearchSetter;
}) { }) {
const { audit, pages, psi } = data; const { audit, pages, lighthouse } = data;
const hasPerformanceTab = psi.length > 0; const hasPerformanceTab = lighthouse.length > 0;
const activeTab = hasPerformanceTab ? tab : "pages"; const activeTab = hasPerformanceTab ? tab : "pages";
const stats = useResultStats(pages, psi); const stats = useResultStats(pages, lighthouse);
return ( return (
<> <>
<StatsGrid <StatsGrid
pagesCrawled={audit.pagesCrawled} pagesCrawled={audit.pagesCrawled}
totalPages={pages.length} totalPages={pages.length}
totalPsi={psi.length} totalLighthouse={lighthouse.length}
averageResponseMs={stats.averageResponseMs} averageResponseMs={stats.averageResponseMs}
psiSummary={stats.psiSummary} lighthouseSummary={stats.lighthouseSummary}
/> />
<div className="card bg-base-100 border border-base-300"> <div className="card bg-base-100 border border-base-300">
<div className="card-body gap-3"> <div className="card-body gap-3">
<ResultsHeader <ResultsHeader
pageCount={pages.length} pageCount={pages.length}
psiCount={psi.length} lighthouseCount={lighthouse.length}
hasPerformanceTab={hasPerformanceTab} hasPerformanceTab={hasPerformanceTab}
activeTab={activeTab} activeTab={activeTab}
setSearchParams={setSearchParams} setSearchParams={setSearchParams}
onExport={(format) => { onExport={(format) => {
if (activeTab === "performance") { if (activeTab === "performance") {
exportPerformance(psi, pages, format); exportPerformance(lighthouse, pages, format);
return; return;
} }
exportPages(pages, format); exportPages(pages, format);
@ -57,8 +58,13 @@ export function ResultsView({
/> />
{activeTab === "pages" && <PagesTable pages={pages} />} {activeTab === "pages" && <PagesTable pages={pages} />}
{activeTab === "performance" && psi.length > 0 && ( {activeTab === "performance" && lighthouse.length > 0 && (
<PerformanceTable projectId={projectId} psi={psi} pages={pages} /> <PerformanceTable
auditId={audit.id}
projectId={projectId}
lighthouse={lighthouse}
pages={pages}
/>
)} )}
</div> </div>
</div> </div>
@ -68,28 +74,34 @@ export function ResultsView({
function useResultStats( function useResultStats(
pages: AuditResultsData["pages"], pages: AuditResultsData["pages"],
psi: AuditResultsData["psi"], lighthouse: AuditResultsData["lighthouse"],
) { ) {
const averageResponseMs = useMemo(() => { const averageResponseMs = useMemo(() => {
if (pages.length === 0) return 0; if (pages.length === 0) return 0;
const total = pages.reduce( const total = pages.reduce(
(sum, page) => sum + (page.responseTimeMs ?? 0), (sum: number, page: AuditResultsData["pages"][number]) =>
sum + (page.responseTimeMs ?? 0),
0, 0,
); );
return Math.round(total / pages.length); return Math.round(total / pages.length);
}, [pages]); }, [pages]);
const psiSummary = useMemo(() => { const lighthouseSummary = useMemo(() => {
const failed = psi.filter((row) => !!row.errorMessage).length; const failed = lighthouse.filter(
const successful = psi.filter((row) => !row.errorMessage); (row: AuditResultsData["lighthouse"][number]) => isLighthouseFailure(row),
).length;
const successful = lighthouse.filter(
(row: AuditResultsData["lighthouse"][number]) =>
!isLighthouseFailure(row),
);
const averageScore = ( const averageScore = (
key: "performanceScore" | "seoScore" | "accessibilityScore", key: "performanceScore" | "seoScore" | "accessibilityScore",
) => { ) => {
const values = successful const values = successful
.map((row) => row[key]) .map((row: AuditResultsData["lighthouse"][number]) => row[key])
.filter((value): value is number => value != null); .filter((value: number | null): value is number => value != null);
if (values.length === 0) return null; if (values.length === 0) return null;
const total = values.reduce((sum, value) => sum + value, 0); const total = values.reduce((sum: number, value) => sum + value, 0);
return Math.round(total / values.length); return Math.round(total / values.length);
}; };
@ -99,21 +111,21 @@ function useResultStats(
avgSeo: averageScore("seoScore"), avgSeo: averageScore("seoScore"),
avgAccessibility: averageScore("accessibilityScore"), avgAccessibility: averageScore("accessibilityScore"),
}; };
}, [psi]); }, [lighthouse]);
return { averageResponseMs, psiSummary }; return { averageResponseMs, lighthouseSummary };
} }
function ResultsHeader({ function ResultsHeader({
pageCount, pageCount,
psiCount, lighthouseCount,
hasPerformanceTab, hasPerformanceTab,
activeTab, activeTab,
setSearchParams, setSearchParams,
onExport, onExport,
}: { }: {
pageCount: number; pageCount: number;
psiCount: number; lighthouseCount: number;
hasPerformanceTab: boolean; hasPerformanceTab: boolean;
activeTab: string; activeTab: string;
setSearchParams: SearchSetter; setSearchParams: SearchSetter;
@ -135,7 +147,7 @@ function ResultsHeader({
className={`tab ${activeTab === "performance" ? "tab-active" : ""}`} className={`tab ${activeTab === "performance" ? "tab-active" : ""}`}
onClick={() => setSearchParams({ tab: "performance" })} onClick={() => setSearchParams({ tab: "performance" })}
> >
Performance ({psiCount}) Performance ({lighthouseCount})
</button> </button>
</div> </div>
) : ( ) : (
@ -150,15 +162,15 @@ function ResultsHeader({
function StatsGrid({ function StatsGrid({
pagesCrawled, pagesCrawled,
totalPages, totalPages,
totalPsi, totalLighthouse,
averageResponseMs, averageResponseMs,
psiSummary, lighthouseSummary,
}: { }: {
pagesCrawled: number; pagesCrawled: number;
totalPages: number; totalPages: number;
totalPsi: number; totalLighthouse: number;
averageResponseMs: number; averageResponseMs: number;
psiSummary: { lighthouseSummary: {
failed: number; failed: number;
avgPerformance: number | null; avgPerformance: number | null;
avgSeo: number | null; avgSeo: number | null;
@ -169,37 +181,43 @@ function StatsGrid({
<div className="grid grid-cols-2 md:grid-cols-4 gap-3"> <div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label="Pages Crawled" value={String(pagesCrawled)} /> <StatCard label="Pages Crawled" value={String(pagesCrawled)} />
<StatCard label="Total URLs" value={String(totalPages)} /> <StatCard label="Total URLs" value={String(totalPages)} />
<StatCard label="PSI Tests" value={String(totalPsi)} /> <StatCard label="Lighthouse Tests" value={String(totalLighthouse)} />
<StatCard label="Avg Response" value={`${averageResponseMs}ms`} /> <StatCard label="Avg Response" value={`${averageResponseMs}ms`} />
{totalPsi > 0 && ( {totalLighthouse > 0 && (
<> <>
<StatCard <StatCard
label="Avg PSI Perf" label="Avg Lighthouse Perf"
value={ value={
psiSummary.avgPerformance == null lighthouseSummary.avgPerformance == null
? "-" ? "-"
: String(psiSummary.avgPerformance) : String(lighthouseSummary.avgPerformance)
} }
className={scoreClass(psiSummary.avgPerformance)} className={scoreClass(lighthouseSummary.avgPerformance)}
/> />
<StatCard <StatCard
label="Avg PSI SEO" label="Avg Lighthouse SEO"
value={psiSummary.avgSeo == null ? "-" : String(psiSummary.avgSeo)}
className={scoreClass(psiSummary.avgSeo)}
/>
<StatCard
label="Avg PSI A11y"
value={ value={
psiSummary.avgAccessibility == null lighthouseSummary.avgSeo == null
? "-" ? "-"
: String(psiSummary.avgAccessibility) : String(lighthouseSummary.avgSeo)
} }
className={scoreClass(psiSummary.avgAccessibility)} className={scoreClass(lighthouseSummary.avgSeo)}
/> />
<StatCard <StatCard
label="PSI Failures" label="Avg Lighthouse A11y"
value={String(psiSummary.failed)} value={
className={psiSummary.failed > 0 ? "text-error" : "text-success"} lighthouseSummary.avgAccessibility == null
? "-"
: String(lighthouseSummary.avgAccessibility)
}
className={scoreClass(lighthouseSummary.avgAccessibility)}
/>
<StatCard
label="Lighthouse Failures"
value={String(lighthouseSummary.failed)}
className={
lighthouseSummary.failed > 0 ? "text-error" : "text-success"
}
/> />
</> </>
)} )}

View File

@ -15,7 +15,7 @@ export function exportPages(
pages: AuditResultsData["pages"], pages: AuditResultsData["pages"],
format: "csv" | "json", format: "csv" | "json",
) { ) {
const rows = pages.map((page) => ({ const rows = pages.map((page: AuditResultsData["pages"][number]) => ({
url: page.url, url: page.url,
statusCode: page.statusCode, statusCode: page.statusCode,
title: page.title ?? "", title: page.title ?? "",
@ -45,7 +45,7 @@ export function exportPages(
"Missing Alt", "Missing Alt",
"Response Time (ms)", "Response Time (ms)",
]; ];
const lines = rows.map((row) => [ const lines = rows.map((row: (typeof rows)[number]) => [
row.url, row.url,
row.statusCode, row.statusCode,
row.title, row.title,
@ -60,12 +60,16 @@ export function exportPages(
} }
export function exportPerformance( export function exportPerformance(
psi: AuditResultsData["psi"], lighthouse: AuditResultsData["lighthouse"],
pages: AuditResultsData["pages"], pages: AuditResultsData["pages"],
format: "csv" | "json", format: "csv" | "json",
) { ) {
const rows = psi.map((result) => { const rows = lighthouse.map(
const page = pages.find((candidate) => candidate.id === result.pageId); (result: AuditResultsData["lighthouse"][number]) => {
const page = pages.find(
(candidate: AuditResultsData["pages"][number]) =>
candidate.id === result.pageId,
);
return { return {
url: page?.url ?? "", url: page?.url ?? "",
strategy: result.strategy, strategy: result.strategy,
@ -77,7 +81,8 @@ export function exportPerformance(
inpMs: result.inpMs, inpMs: result.inpMs,
ttfbMs: result.ttfbMs, ttfbMs: result.ttfbMs,
}; };
}); },
);
if (format === "json") { if (format === "json") {
downloadFile( downloadFile(
@ -99,7 +104,7 @@ export function exportPerformance(
"INP (ms)", "INP (ms)",
"TTFB (ms)", "TTFB (ms)",
]; ];
const lines = rows.map((row) => [ const lines = rows.map((row: (typeof rows)[number]) => [
row.url, row.url,
row.strategy, row.strategy,
row.performance, row.performance,

View File

@ -70,7 +70,7 @@ export function HttpStatusBadge({ code }: { code: number | null }) {
return <span className="badge badge-error badge-sm">{code}</span>; return <span className="badge badge-error badge-sm">{code}</span>;
} }
export function PsiScoreBadge({ score }: { score: number | null }) { export function LighthouseScoreBadge({ score }: { score: number | null }) {
if (score == null) { if (score == null) {
return <span className="text-xs text-base-content/40">-</span>; return <span className="text-xs text-base-content/40">-</span>;
} }

View File

@ -0,0 +1,164 @@
import { useState, type ReactNode } from "react";
import {
ChevronRight,
ExternalLink,
FileWarning,
Info,
TriangleAlert,
} from "lucide-react";
import type { LighthouseIssue } from "./types";
export function LighthouseIssueRow({ issue }: { issue: LighthouseIssue }) {
const [open, setOpen] = useState(false);
const hasDetails = !!(issue.description || issue.items.length > 0);
return (
<>
<tr
className={`hover:bg-base-200/50 transition-colors ${hasDetails ? "cursor-pointer" : ""}`}
onClick={() => hasDetails && setOpen(!open)}
>
<td className="py-3 pl-4 pr-2">
{hasDetails ? (
<ChevronRight
className={`size-3.5 text-base-content/40 transition-transform ${open ? "rotate-90" : ""}`}
/>
) : null}
</td>
<td className="py-3 pr-3">
<span
className={`badge badge-sm border ${severityBadgeClass(issue.severity)} gap-1`}
>
{severityIcon(issue.severity)}
{issue.severity}
</span>
</td>
<td className="py-3 pr-3">
<div>
<p className="font-medium text-sm leading-snug">{issue.title}</p>
{issue.displayValue ? (
<p className="text-xs text-base-content/50 mt-0.5">
{issue.displayValue}
</p>
) : null}
</div>
</td>
<td className="py-3 pr-3 hidden sm:table-cell">
<span className="text-xs text-base-content/50">{issue.category}</span>
</td>
<td className="py-3 pr-3 hidden md:table-cell text-right">
{issue.impactMs != null || issue.impactBytes != null ? (
<span className="text-xs tabular-nums text-base-content/50">
{issue.impactMs ? formatMs(issue.impactMs) : null}
{issue.impactMs && issue.impactBytes ? " / " : null}
{issue.impactBytes ? formatBytes(issue.impactBytes) : null}
</span>
) : null}
</td>
<td className="py-3 pr-4 text-right">
{issue.score != null ? (
<span className="text-xs tabular-nums text-base-content/50">
{issue.score}
</span>
) : null}
</td>
</tr>
{open ? (
<tr className="!bg-transparent">
<td colSpan={6} className="pb-4 pt-2 pl-[8.5rem] pr-4">
<div className="space-y-3">
{issue.description ? (
<div className="text-sm text-base-content/70 leading-relaxed">
{renderInlineMarkdown(issue.description)}
</div>
) : null}
{issue.items.length > 0 ? (
<details className="text-sm">
<summary className="cursor-pointer font-medium text-base-content/60 text-xs">
Affected items ({issue.items.length})
</summary>
<div className="mt-2 space-y-1.5">
{issue.items.map((item, itemIndex) => (
<pre
key={`${issue.auditKey}-${itemIndex}`}
className="bg-base-200/60 p-2 rounded overflow-x-auto text-xs leading-relaxed"
>
{item}
</pre>
))}
</div>
</details>
) : null}
</div>
</td>
</tr>
) : null}
</>
);
}
function formatMs(ms: number) {
if (ms >= 1000) return `${(ms / 1000).toFixed(1)}s`;
return `${ms}ms`;
}
function formatBytes(bytes: number) {
if (bytes === 0) return "0 B";
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(0)} KB`;
return `${bytes} B`;
}
function renderInlineMarkdown(markdown: string): ReactNode {
const linkPattern = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g;
const nodes: ReactNode[] = [];
let cursor = 0;
let match = linkPattern.exec(markdown);
while (match) {
const [raw, label, href] = match;
const index = match.index;
if (index > cursor) {
nodes.push(markdown.slice(cursor, index));
}
nodes.push(
<a
key={`${href}-${index}`}
href={href}
target="_blank"
rel="noopener noreferrer"
className="link link-primary inline-flex items-center gap-1"
>
{label}
<ExternalLink className="size-3" />
</a>,
);
cursor = index + raw.length;
match = linkPattern.exec(markdown);
}
if (cursor < markdown.length) {
nodes.push(markdown.slice(cursor));
}
return nodes.length ? nodes : markdown;
}
function severityBadgeClass(severity: "critical" | "warning" | "info") {
if (severity === "critical") {
return "border-error/30 bg-error/10 text-error/80";
}
if (severity === "warning") {
return "border-warning/35 bg-warning/10 text-warning/80";
}
return "border-info/30 bg-info/10 text-info/80";
}
function severityIcon(severity: "critical" | "warning" | "info") {
if (severity === "critical") return <FileWarning className="size-3" />;
if (severity === "warning") return <TriangleAlert className="size-3" />;
return <Info className="size-3" />;
}

View File

@ -6,26 +6,33 @@ import {
Info, Info,
TriangleAlert, TriangleAlert,
} from "lucide-react"; } from "lucide-react";
import type { CategoryTab, ExportPayload, PsiIssue } from "./types"; import type {
import { CategoryTab,
categoryLabel, ExportPayload,
renderInlineMarkdown, LighthouseIssue,
severityBadgeClass, LighthouseMetrics,
severityIcon, LighthouseScores,
} from "./utils"; } from "./types";
import { LighthouseIssueRow } from "./LighthouseIssueRow";
import { LighthouseIssuesSummary } from "./LighthouseIssuesSummary";
import { categoryLabel } from "./utils";
import { categoryTabs } from "./types"; import { categoryTabs } from "./types";
export function PsiIssuesHeader({ export function LighthouseIssuesHeader({
backLabel, backLabel,
onBack, onBack,
scannedAt, scannedAt,
finalUrl, finalUrl,
scores,
metrics,
severityCounts, severityCounts,
}: { }: {
backLabel: string; backLabel: string;
onBack: () => void; onBack: () => void;
scannedAt?: string; scannedAt?: string;
finalUrl?: string; finalUrl?: string;
scores?: LighthouseScores | null;
metrics?: LighthouseMetrics | null;
severityCounts: { critical: number; warning: number; info: number }; severityCounts: { critical: number; warning: number; info: number };
}) { }) {
return ( return (
@ -44,11 +51,12 @@ export function PsiIssuesHeader({
<div className="card bg-base-100 border border-base-300"> <div className="card bg-base-100 border border-base-300">
<div className="card-body py-5 gap-4"> <div className="card-body py-5 gap-4">
<div className="space-y-1"> <div className="space-y-1">
<h1 className="text-2xl font-semibold">PSI Issues</h1> <h1 className="text-2xl font-semibold">Lighthouse Issues</h1>
<p className="text-sm text-base-content/70 break-all"> <p className="text-sm text-base-content/70 break-all">
{finalUrl ?? "Loading URL..."} {finalUrl ?? "Loading URL..."}
</p> </p>
</div> </div>
<LighthouseIssuesSummary scores={scores} metrics={metrics} />
<div className="flex flex-wrap gap-2 text-xs"> <div className="flex flex-wrap gap-2 text-xs">
<span className="badge border border-error/30 bg-error/10 text-error/80 gap-1"> <span className="badge border border-error/30 bg-error/10 text-error/80 gap-1">
<FileWarning className="size-3" /> <FileWarning className="size-3" />
@ -69,7 +77,7 @@ export function PsiIssuesHeader({
); );
} }
export function PsiIssuesToolbar({ export function LighthouseIssuesToolbar({
category, category,
categoryCounts, categoryCounts,
selectedCategoryLabel, selectedCategoryLabel,
@ -85,12 +93,12 @@ export function PsiIssuesToolbar({
categoryCounts: Record<CategoryTab, number>; categoryCounts: Record<CategoryTab, number>;
selectedCategoryLabel: string; selectedCategoryLabel: string;
isBusy: boolean; isBusy: boolean;
visibleIssues: PsiIssue[]; visibleIssues: LighthouseIssue[];
allIssues: PsiIssue[]; allIssues: LighthouseIssue[];
onCategoryChange: (next: CategoryTab) => void; onCategoryChange: (next: CategoryTab) => void;
onCopy: (data: ExportPayload, toastMessage: string) => void; onCopy: (data: ExportPayload, toastMessage: string) => void;
onExport: (data: ExportPayload) => void; onExport: (data: ExportPayload) => void;
onExportCsv: (issues: PsiIssue[], variant: "all" | "current") => void; onExportCsv: (issues: LighthouseIssue[], variant: "all" | "current") => void;
}) { }) {
const exportCurrentCategory: ExportPayload = const exportCurrentCategory: ExportPayload =
category === "all" ? { mode: "issues" } : { mode: "category", category }; category === "all" ? { mode: "issues" } : { mode: "category", category };
@ -161,14 +169,14 @@ function ExportMenu({
onExportCsv, onExportCsv,
visibleIssues, visibleIssues,
}: { }: {
allIssues: PsiIssue[]; allIssues: LighthouseIssue[];
categoryLabelLower: string; categoryLabelLower: string;
exportCurrentCategory: ExportPayload; exportCurrentCategory: ExportPayload;
isBusy: boolean; isBusy: boolean;
onCopy: (data: ExportPayload, toastMessage: string) => void; onCopy: (data: ExportPayload, toastMessage: string) => void;
onExport: (data: ExportPayload) => void; onExport: (data: ExportPayload) => void;
onExportCsv: (issues: PsiIssue[], variant: "all" | "current") => void; onExportCsv: (issues: LighthouseIssue[], variant: "all" | "current") => void;
visibleIssues: PsiIssue[]; visibleIssues: LighthouseIssue[];
}) { }) {
return ( return (
<div className="dropdown dropdown-end"> <div className="dropdown dropdown-end">
@ -201,21 +209,23 @@ function ExportMenu({
<li> <li>
<button <button
disabled={isBusy} disabled={isBusy}
onClick={() => onCopy({ mode: "issues" }, "Copied all issues")} onClick={() =>
onCopy({ mode: "issues" }, "Copied all actionable issues")
}
> >
<Copy className="size-4" /> <Copy className="size-4" />
Copy all issues Copy all actionable issues
</button> </button>
</li> </li>
<li> <li>
<button <button
disabled={isBusy} disabled={isBusy}
onClick={() => onClick={() =>
onCopy({ mode: "full" }, "Copied full Lighthouse report") onCopy({ mode: "full" }, "Copied saved Lighthouse payload")
} }
> >
<Copy className="size-4" /> <Copy className="size-4" />
Copy full Lighthouse report Copy saved Lighthouse payload
</button> </button>
</li> </li>
<li className="menu-title"> <li className="menu-title">
@ -234,12 +244,12 @@ function ExportMenu({
disabled={isBusy} disabled={isBusy}
onClick={() => onExport({ mode: "issues" })} onClick={() => onExport({ mode: "issues" })}
> >
Download all issues Download all actionable issues
</button> </button>
</li> </li>
<li> <li>
<button disabled={isBusy} onClick={() => onExport({ mode: "full" })}> <button disabled={isBusy} onClick={() => onExport({ mode: "full" })}>
Download full Lighthouse report Download saved Lighthouse payload
</button> </button>
</li> </li>
<li className="menu-title"> <li className="menu-title">
@ -258,7 +268,7 @@ function ExportMenu({
disabled={!allIssues.length} disabled={!allIssues.length}
onClick={() => onExportCsv(allIssues, "all")} onClick={() => onExportCsv(allIssues, "all")}
> >
Download all issues Download all actionable issues
</button> </button>
</li> </li>
</ul> </ul>
@ -266,12 +276,14 @@ function ExportMenu({
); );
} }
export function PsiIssueList({ export function LighthouseIssueList({
issues, issues,
isLoading, isLoading,
emptyMessage,
}: { }: {
issues: PsiIssue[]; issues: LighthouseIssue[];
isLoading: boolean; isLoading: boolean;
emptyMessage?: string;
}) { }) {
if (isLoading) { if (isLoading) {
return <p className="text-sm text-base-content/60">Loading issues...</p>; return <p className="text-sm text-base-content/60">Loading issues...</p>;
@ -279,84 +291,40 @@ export function PsiIssueList({
if (!issues.length) { if (!issues.length) {
return ( return (
<p className="text-sm text-base-content/60"> <p className="text-sm text-base-content/60">
No unresolved issues for this category. {emptyMessage ?? "No actionable issues for this category."}
</p> </p>
); );
} }
return ( return (
<div className="space-y-3"> <table className="table table-sm w-full table-fixed">
{issues.map((issue) => ( <colgroup>
<PsiIssueCard <col className="w-8" />
key={`${issue.category}-${issue.auditKey}`} <col className="w-24" />
<col />
<col className="w-28 hidden sm:table-column" />
<col className="w-28 hidden md:table-column" />
<col className="w-14" />
</colgroup>
<thead>
<tr className="text-xs text-base-content/50 uppercase tracking-wide border-b border-base-300">
<th />
<th className="font-medium">Severity</th>
<th className="font-medium">Issue</th>
<th className="font-medium hidden sm:table-cell">Category</th>
<th className="font-medium hidden md:table-cell text-right">
Impact
</th>
<th className="font-medium text-right">Score</th>
</tr>
</thead>
<tbody className="divide-y divide-base-300/60">
{issues.map((issue, issueIndex) => (
<LighthouseIssueRow
key={`${issue.category}-${issue.auditKey}-${issueIndex}`}
issue={issue} issue={issue}
/> />
))} ))}
</div> </tbody>
); </table>
}
function PsiIssueCard({ issue }: { issue: PsiIssue }) {
return (
<div className="card bg-base-200/30 border border-base-300">
<div className="card-body p-5 gap-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-wrap items-center gap-2">
<span className="badge badge-outline">{issue.category}</span>
<span
className={`badge border ${severityBadgeClass(issue.severity)} gap-1`}
>
{severityIcon(issue.severity)}
{issue.severity}
</span>
{issue.score != null ? (
<div
className="tooltip tooltip-top"
data-tip="Lighthouse score from 0-100 for this audit. Lower means larger opportunity for improvement."
>
<span className="badge badge-ghost cursor-help">
Score {issue.score}
</span>
</div>
) : null}
</div>
{issue.impactMs != null || issue.impactBytes != null ? (
<span className="text-xs text-base-content/60">
Impact {issue.impactMs ?? 0}ms / {issue.impactBytes ?? 0} bytes
</span>
) : null}
</div>
<p className="font-semibold leading-tight">{issue.title}</p>
{issue.displayValue ? (
<p className="text-sm text-base-content/70">{issue.displayValue}</p>
) : null}
{issue.description ? (
<div className="text-sm text-base-content/80 leading-relaxed">
{renderInlineMarkdown(issue.description)}
</div>
) : null}
{issue.items.length > 0 ? (
<details className="text-sm bg-base-100 rounded-box border border-base-300/80 px-3 py-2">
<summary className="cursor-pointer font-medium text-base-content/75">
Affected items ({issue.items.length})
</summary>
<div className="mt-2 space-y-2">
{issue.items.map((item) => (
<pre
key={`${issue.auditKey}-${item}`}
className="bg-base-200/60 p-2 rounded-box overflow-x-auto text-xs"
>
{item}
</pre>
))}
</div>
</details>
) : null}
</div>
</div>
); );
} }

View File

@ -1,7 +1,11 @@
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { AlertCircle, TriangleAlert } from "lucide-react";
import { toast } from "sonner"; import { toast } from "sonner";
import { exportAuditPsi, getAuditPsiIssues } from "@/serverFunctions/psi"; import {
import type { CategoryTab, ExportPayload, PsiIssue } from "./types"; exportAuditLighthouseIssues,
getAuditLighthouseIssues,
} from "@/serverFunctions/lighthouse";
import type { CategoryTab, ExportPayload, LighthouseIssue } from "./types";
import { import {
categoryLabel, categoryLabel,
categorySlug, categorySlug,
@ -9,13 +13,13 @@ import {
issuesToCsv, issuesToCsv,
} from "./utils"; } from "./utils";
import { import {
PsiIssueList, LighthouseIssueList,
PsiIssuesHeader, LighthouseIssuesHeader,
PsiIssuesToolbar, LighthouseIssuesToolbar,
} from "./PsiIssuesParts"; } from "./LighthouseIssuesParts";
import { categoryTabs } from "./types"; import { categoryTabs } from "./types";
type PsiIssuesScreenProps = { type LighthouseIssuesScreenProps = {
projectId: string; projectId: string;
resultId: string; resultId: string;
category: CategoryTab; category: CategoryTab;
@ -24,26 +28,14 @@ type PsiIssuesScreenProps = {
onCategoryChange: (next: CategoryTab) => void; onCategoryChange: (next: CategoryTab) => void;
}; };
export function PsiIssuesScreen(props: PsiIssuesScreenProps) { export function LighthouseIssuesScreen(props: LighthouseIssuesScreenProps) {
const { projectId, resultId, category, backLabel, onBack, onCategoryChange } = const { projectId, resultId, category, backLabel, onBack, onCategoryChange } =
props; props;
const issuesQuery = useQuery({ const issuesQuery = useQuery({
queryKey: ["auditPsiIssues", projectId, resultId, category], queryKey: ["auditLighthouseIssues", projectId, resultId],
queryFn: () => queryFn: () =>
getAuditPsiIssues({ getAuditLighthouseIssues({
data: {
projectId,
resultId,
category: category === "all" ? undefined : category,
},
}),
});
const summaryQuery = useQuery({
queryKey: ["auditPsiIssuesSummary", projectId, resultId],
queryFn: () =>
getAuditPsiIssues({
data: { data: {
projectId, projectId,
resultId, resultId,
@ -52,8 +44,10 @@ export function PsiIssuesScreen(props: PsiIssuesScreenProps) {
}); });
const exportMutation = useMutation({ const exportMutation = useMutation({
mutationFn: (data: ExportPayload) => mutationFn: (
exportAuditPsi({ data: ExportPayload,
): Promise<{ filename: string; content: string }> =>
exportAuditLighthouseIssues({
data: { data: {
projectId, projectId,
resultId, resultId,
@ -71,27 +65,56 @@ export function PsiIssuesScreen(props: PsiIssuesScreenProps) {
selectedCategoryLabel, selectedCategoryLabel,
severityCounts, severityCounts,
visibleIssues, visibleIssues,
} = usePsiIssuesActions({ } = useLighthouseIssuesActions({
category, category,
exportMutation, exportMutation,
issues: (issuesQuery.data?.issues ?? []) as PsiIssue[], allIssues: issuesQuery.data?.issues ?? [],
summaryIssues: summaryQuery.data?.issues,
}); });
const issuesErrorMessage =
issuesQuery.error instanceof Error
? issuesQuery.error.message
: "Failed to load Lighthouse issues.";
const showsLegacyPayloadNotice =
issuesQuery.data != null && !issuesQuery.data.hasIssueDetails;
const emptyMessage = showsLegacyPayloadNotice
? "This audit was saved without issue-level Lighthouse details. Re-run the audit to populate this screen."
: undefined;
return ( return (
<div className="px-4 py-3 md:px-6 md:py-4 pb-24 md:pb-8 overflow-auto"> <div className="px-4 py-3 md:px-6 md:py-4 pb-24 md:pb-8 overflow-auto">
<div className="mx-auto max-w-5xl space-y-4"> <div className="mx-auto max-w-5xl space-y-4">
<PsiIssuesHeader <LighthouseIssuesHeader
backLabel={backLabel} backLabel={backLabel}
onBack={onBack} onBack={onBack}
scannedAt={issuesQuery.data?.createdAt} scannedAt={issuesQuery.data?.createdAt}
finalUrl={issuesQuery.data?.finalUrl} finalUrl={issuesQuery.data?.finalUrl}
scores={issuesQuery.data?.scores}
metrics={issuesQuery.data?.metrics}
severityCounts={severityCounts} severityCounts={severityCounts}
/> />
<div className="card bg-base-100 border border-base-300"> <div className="card bg-base-100 border border-base-300">
<div className="card-body gap-4"> <div className="card-body gap-4">
<PsiIssuesToolbar {issuesQuery.isError ? (
<div className="alert alert-error">
<AlertCircle className="size-4" />
<span>{issuesErrorMessage}</span>
</div>
) : null}
{showsLegacyPayloadNotice ? (
<div className="alert alert-warning">
<TriangleAlert className="size-4" />
<span>
This Lighthouse run was stored before issue details were
preserved. Re-run the audit to see category counts and issue
cards.
</span>
</div>
) : null}
<LighthouseIssuesToolbar
category={category} category={category}
categoryCounts={categoryCounts} categoryCounts={categoryCounts}
selectedCategoryLabel={selectedCategoryLabel} selectedCategoryLabel={selectedCategoryLabel}
@ -107,9 +130,10 @@ export function PsiIssuesScreen(props: PsiIssuesScreenProps) {
}} }}
onExportCsv={runExportCsv} onExportCsv={runExportCsv}
/> />
<PsiIssueList <LighthouseIssueList
issues={visibleIssues} issues={visibleIssues}
isLoading={issuesQuery.isLoading} isLoading={issuesQuery.isLoading}
emptyMessage={emptyMessage}
/> />
</div> </div>
</div> </div>
@ -118,23 +142,23 @@ export function PsiIssuesScreen(props: PsiIssuesScreenProps) {
); );
} }
function usePsiIssuesActions({ function useLighthouseIssuesActions({
allIssues,
category, category,
exportMutation, exportMutation,
issues,
summaryIssues,
}: { }: {
allIssues: LighthouseIssue[];
category: CategoryTab; category: CategoryTab;
exportMutation: { exportMutation: {
mutateAsync: ( mutateAsync: (
data: ExportPayload, data: ExportPayload,
) => Promise<{ filename: string; content: string }>; ) => Promise<{ filename: string; content: string }>;
}; };
issues: PsiIssue[];
summaryIssues: PsiIssue[] | undefined;
}) { }) {
const visibleIssues = issues; const visibleIssues =
const allIssues = summaryIssues ?? visibleIssues; category === "all"
? allIssues
: allIssues.filter((issue) => issue.category === category);
const selectedCategoryLabel = categoryLabel(category); const selectedCategoryLabel = categoryLabel(category);
const categoryCounts = getCategoryCounts(allIssues); const categoryCounts = getCategoryCounts(allIssues);
const severityCounts = getSeverityCounts(visibleIssues); const severityCounts = getSeverityCounts(visibleIssues);
@ -151,8 +175,11 @@ function usePsiIssuesActions({
} }
}; };
const runExportCsv = (rows: PsiIssue[], variant: "all" | "current") => { const runExportCsv = (
const filename = `psi-${variant}-${categorySlug(category)}-issues.csv`; rows: LighthouseIssue[],
variant: "all" | "current",
) => {
const filename = `lighthouse-${variant}-${categorySlug(category)}-issues.csv`;
downloadTextFile(filename, issuesToCsv(rows), "text/csv"); downloadTextFile(filename, issuesToCsv(rows), "text/csv");
toast.success("CSV download started"); toast.success("CSV download started");
}; };
@ -181,7 +208,9 @@ function usePsiIssuesActions({
}; };
} }
function getCategoryCounts(allIssues: PsiIssue[]): Record<CategoryTab, number> { function getCategoryCounts(
allIssues: LighthouseIssue[],
): Record<CategoryTab, number> {
return categoryTabs.reduce<Record<CategoryTab, number>>( return categoryTabs.reduce<Record<CategoryTab, number>>(
(acc, tab) => { (acc, tab) => {
if (tab === "all") { if (tab === "all") {
@ -201,7 +230,7 @@ function getCategoryCounts(allIssues: PsiIssue[]): Record<CategoryTab, number> {
); );
} }
function getSeverityCounts(issues: PsiIssue[]) { function getSeverityCounts(issues: LighthouseIssue[]) {
return { return {
critical: issues.filter((issue) => issue.severity === "critical").length, critical: issues.filter((issue) => issue.severity === "critical").length,
warning: issues.filter((issue) => issue.severity === "warning").length, warning: issues.filter((issue) => issue.severity === "warning").length,

View File

@ -0,0 +1,121 @@
import type { LighthouseMetrics, LighthouseScores } from "./types";
export function LighthouseIssuesSummary({
scores,
metrics,
}: {
scores?: LighthouseScores | null;
metrics?: LighthouseMetrics | null;
}) {
const metricItems = getMetricItems(metrics);
if (!scores && metricItems.length === 0) {
return null;
}
return (
<>
{scores ? (
<div className="grid grid-cols-4 gap-3">
<ScoreGauge label="Performance" score={scores.performance} />
<ScoreGauge label="Accessibility" score={scores.accessibility} />
<ScoreGauge label="Best Practices" score={scores["best-practices"]} />
<ScoreGauge label="SEO" score={scores.seo} />
</div>
) : null}
{metricItems.length > 0 ? (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-x-4 gap-y-1 rounded-box border border-base-300 bg-base-200/25 px-4 py-3">
{metricItems.map((metric) => (
<div
key={metric.label}
className="flex items-baseline justify-between gap-2 py-1"
>
<span className="text-xs text-base-content/50 uppercase tracking-wide">
{metric.label}
</span>
<span className="text-sm font-semibold tabular-nums text-base-content">
{metric.value}
</span>
</div>
))}
</div>
) : null}
</>
);
}
function scoreColor(score: number | null) {
if (score == null) return "text-base-content/40";
if (score >= 90) return "text-success";
if (score >= 50) return "text-warning";
return "text-error";
}
function scoreStrokeColor(score: number | null) {
if (score == null) return "stroke-base-content/20";
if (score >= 90) return "stroke-success";
if (score >= 50) return "stroke-warning";
return "stroke-error";
}
function ScoreGauge({ label, score }: { label: string; score: number | null }) {
const displayScore = score ?? 0;
const radius = 28;
const circumference = 2 * Math.PI * radius;
const progress = (displayScore / 100) * circumference;
return (
<div className="flex flex-col items-center gap-1.5 py-2">
<div className="relative size-16">
<svg viewBox="0 0 64 64" className="size-full -rotate-90">
<circle
cx="32"
cy="32"
r={radius}
fill="none"
strokeWidth="4"
className="stroke-base-300/60"
/>
{score != null ? (
<circle
cx="32"
cy="32"
r={radius}
fill="none"
strokeWidth="4"
strokeLinecap="round"
strokeDasharray={`${progress} ${circumference}`}
className={scoreStrokeColor(score)}
/>
) : null}
</svg>
<span
className={`absolute inset-0 flex items-center justify-center text-lg font-bold ${scoreColor(score)}`}
>
{score ?? "-"}
</span>
</div>
<span className="text-[11px] text-base-content/55 text-center leading-tight">
{label}
</span>
</div>
);
}
function getMetricItems(metrics?: LighthouseMetrics | null) {
if (!metrics) return [];
return [
{ label: "FCP", value: metrics.firstContentfulPaint.displayValue },
{ label: "LCP", value: metrics.largestContentfulPaint.displayValue },
{ label: "TBT", value: metrics.totalBlockingTime.displayValue },
{ label: "SI", value: metrics.speedIndex.displayValue },
{ label: "TTI", value: metrics.timeToInteractive.displayValue },
{ label: "CLS", value: metrics.cumulativeLayoutShift.displayValue },
{ label: "INP", value: metrics.interactionToNextPaint.displayValue },
{ label: "TTFB", value: metrics.serverResponseTime.displayValue },
].filter(
(metric): metric is { label: string; value: string } =>
metric.value != null,
);
}

View File

@ -0,0 +1,26 @@
import type { z } from "zod";
import type { getAuditLighthouseIssues } from "@/serverFunctions/lighthouse";
import {
LIGHTHOUSE_CATEGORY_TABS,
type LighthouseCategoryTab,
} from "@/shared/lighthouse";
import type { lighthouseAuditExportSchema } from "@/types/schemas/lighthouse";
export const categoryTabs = LIGHTHOUSE_CATEGORY_TABS;
export type CategoryTab = LighthouseCategoryTab;
export type ExportPayload = Omit<
z.infer<typeof lighthouseAuditExportSchema>,
"projectId" | "resultId"
>;
type LighthouseIssuesResponse = Awaited<
ReturnType<typeof getAuditLighthouseIssues>
>;
export type LighthouseIssue = LighthouseIssuesResponse["issues"][number];
export type LighthouseScores = NonNullable<LighthouseIssuesResponse["scores"]>;
export type LighthouseMetrics = NonNullable<
LighthouseIssuesResponse["metrics"]
>;

View File

@ -0,0 +1,53 @@
import { buildCsv } from "@/client/lib/csv";
import type { CategoryTab, LighthouseIssue } from "./types";
export function categoryLabel(category: CategoryTab) {
if (category === "best-practices") return "Best practices";
if (category === "all") return "All";
return `${category.charAt(0).toUpperCase()}${category.slice(1)}`;
}
export function categorySlug(category: CategoryTab) {
return category === "all" ? "all" : category;
}
export function issuesToCsv(issues: LighthouseIssue[]) {
const headers = [
"Category",
"Severity",
"Score",
"Title",
"Display Value",
"Description",
"Impact (ms)",
"Impact (bytes)",
"Affected Items",
];
const rows = issues.map((issue) => [
issue.category,
issue.severity,
issue.score ?? "",
issue.title,
issue.displayValue ?? "",
issue.description ?? "",
issue.impactMs ?? "",
issue.impactBytes ?? "",
issue.items.length,
]);
return buildCsv(headers, rows);
}
export function downloadTextFile(
filename: string,
content: string,
mimeType: string,
) {
const blob = new Blob([content], { type: mimeType });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
URL.revokeObjectURL(link.href);
}

View File

@ -1,28 +0,0 @@
export const categoryTabs = [
"all",
"performance",
"accessibility",
"best-practices",
"seo",
] as const;
export type CategoryTab = (typeof categoryTabs)[number];
export type IssueCategory = Exclude<CategoryTab, "all">;
export type ExportPayload = {
mode: "full" | "issues" | "category";
category?: IssueCategory;
};
export type PsiIssue = {
auditKey: string;
category: IssueCategory;
severity: "critical" | "warning" | "info";
score?: number | null;
title: string;
displayValue?: string | null;
description?: string | null;
impactMs?: number | null;
impactBytes?: number | null;
items: string[];
};

View File

@ -1,113 +0,0 @@
import type { ReactNode } from "react";
import { ExternalLink, FileWarning, Info, TriangleAlert } from "lucide-react";
import { buildCsv } from "@/client/lib/csv";
import type { CategoryTab, PsiIssue } from "./types";
export function categoryLabel(category: CategoryTab) {
if (category === "best-practices") return "Best practices";
if (category === "all") return "All";
return `${category.charAt(0).toUpperCase()}${category.slice(1)}`;
}
export function categorySlug(category: CategoryTab) {
return category === "all" ? "all" : category;
}
export function issuesToCsv(issues: PsiIssue[]) {
const headers = [
"Category",
"Severity",
"Score",
"Title",
"Display Value",
"Description",
"Impact (ms)",
"Impact (bytes)",
"Affected Items",
];
const rows = issues.map((issue) => [
issue.category,
issue.severity,
issue.score ?? "",
issue.title,
issue.displayValue ?? "",
issue.description ?? "",
issue.impactMs ?? "",
issue.impactBytes ?? "",
issue.items.length,
]);
return buildCsv(headers, rows);
}
export function renderInlineMarkdown(markdown: string): ReactNode {
const linkPattern = /\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g;
const nodes: ReactNode[] = [];
let cursor = 0;
let match = linkPattern.exec(markdown);
while (match) {
const [raw, label, href] = match;
const index = match.index;
if (index > cursor) {
nodes.push(markdown.slice(cursor, index));
}
nodes.push(
<a
key={`${href}-${index}`}
href={href}
target="_blank"
rel="noopener noreferrer"
className="link link-primary inline-flex items-center gap-1"
>
{label}
<ExternalLink className="size-3" />
</a>,
);
cursor = index + raw.length;
match = linkPattern.exec(markdown);
}
if (cursor < markdown.length) {
nodes.push(markdown.slice(cursor));
}
if (!nodes.length) {
return markdown;
}
return nodes;
}
export function downloadTextFile(
filename: string,
content: string,
mimeType: string,
) {
const blob = new Blob([content], { type: mimeType });
const link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
URL.revokeObjectURL(link.href);
}
export function severityBadgeClass(severity: "critical" | "warning" | "info") {
if (severity === "critical") {
return "border-error/30 bg-error/10 text-error/80";
}
if (severity === "warning") {
return "border-warning/35 bg-warning/10 text-warning/80";
}
return "border-info/30 bg-info/10 text-info/80";
}
export function severityIcon(severity: "critical" | "warning" | "info") {
if (severity === "critical") return <FileWarning className="size-3" />;
if (severity === "warning") return <TriangleAlert className="size-3" />;
return <Info className="size-3" />;
}

View File

@ -27,9 +27,6 @@ export const projects = sqliteTable("projects", {
.references(() => organization.id, { onDelete: "cascade" }), .references(() => organization.id, { onDelete: "cascade" }),
name: text("name").notNull(), name: text("name").notNull(),
domain: text("domain"), domain: text("domain"),
// PSI keys are used for Google API abuse-control, not direct billing.
// We still keep handling explicit to make the tradeoff obvious.
pagespeedApiKey: text("pagespeed_api_key"),
createdAt: text("created_at") createdAt: text("created_at")
.notNull() .notNull()
.default(sql`(current_timestamp)`), .default(sql`(current_timestamp)`),
@ -123,14 +120,14 @@ export const audits = sqliteTable(
.notNull() .notNull()
.default("running"), .default("running"),
workflowInstanceId: text("workflow_instance_id"), workflowInstanceId: text("workflow_instance_id"),
// JSON config: { maxPages, psiStrategy, psiApiKey? } // JSON config: { maxPages, lighthouseStrategy }
config: text("config").notNull().default("{}"), config: text("config").notNull().default("{}"),
// Progress & summary // Progress & summary
pagesCrawled: integer("pages_crawled").notNull().default(0), pagesCrawled: integer("pages_crawled").notNull().default(0),
pagesTotal: integer("pages_total").notNull().default(0), pagesTotal: integer("pages_total").notNull().default(0),
psiTotal: integer("psi_total").notNull().default(0), lighthouseTotal: integer("lighthouse_total").notNull().default(0),
psiCompleted: integer("psi_completed").notNull().default(0), lighthouseCompleted: integer("lighthouse_completed").notNull().default(0),
psiFailed: integer("psi_failed").notNull().default(0), lighthouseFailed: integer("lighthouse_failed").notNull().default(0),
currentPhase: text("current_phase").default("discovery"), currentPhase: text("current_phase").default("discovery"),
startedAt: text("started_at") startedAt: text("started_at")
.notNull() .notNull()
@ -196,10 +193,9 @@ export const auditPages = sqliteTable(
(table) => [index("audit_pages_audit_id_idx").on(table.auditId)], (table) => [index("audit_pages_audit_id_idx").on(table.auditId)],
); );
// PSI summaries captured as part of a site audit run. // One row per Lighthouse test (mobile + desktop per page).
// These belong to audit pages and are the only PSI result records we keep. export const auditLighthouseResults = sqliteTable(
export const auditPsiResults = sqliteTable( "audit_lighthouse_results",
"audit_psi_results",
{ {
id: text("id").primaryKey(), id: text("id").primaryKey(),
auditId: text("audit_id") auditId: text("audit_id")
@ -221,5 +217,5 @@ export const auditPsiResults = sqliteTable(
r2Key: text("r2_key"), r2Key: text("r2_key"),
payloadSizeBytes: integer("payload_size_bytes"), payloadSizeBytes: integer("payload_size_bytes"),
}, },
(table) => [index("audit_psi_results_audit_id_idx").on(table.auditId)], (table) => [index("audit_lighthouse_results_audit_id_idx").on(table.auditId)],
); );

View File

@ -199,9 +199,9 @@ function ProgressCard({
status: { status: {
pagesCrawled: number; pagesCrawled: number;
pagesTotal: number; pagesTotal: number;
psiTotal: number; lighthouseTotal: number;
psiCompleted: number; lighthouseCompleted: number;
psiFailed: number; lighthouseFailed: number;
currentPhase: string | null; currentPhase: string | null;
}; };
}) { }) {
@ -209,21 +209,23 @@ function ProgressCard({
status.pagesTotal > 0 status.pagesTotal > 0
? Math.round((status.pagesCrawled / status.pagesTotal) * 100) ? Math.round((status.pagesCrawled / status.pagesTotal) * 100)
: 0; : 0;
const psiDone = status.psiCompleted + status.psiFailed; const lighthouseDone = status.lighthouseCompleted + status.lighthouseFailed;
const psiProgress = const lighthouseProgress =
status.psiTotal > 0 ? Math.round((psiDone / status.psiTotal) * 100) : 0; status.lighthouseTotal > 0
const isPsiPhase = status.currentPhase === "psi"; ? Math.round((lighthouseDone / status.lighthouseTotal) * 100)
: 0;
const isLighthousePhase = status.currentPhase === "lighthouse";
const phaseLabel = const phaseLabel =
status.currentPhase === "discovery" status.currentPhase === "discovery"
? "Discovery" ? "Discovery"
: status.currentPhase === "crawling" : status.currentPhase === "crawling"
? "Crawling" ? "Crawling"
: status.currentPhase === "psi" : status.currentPhase === "lighthouse"
? "PSI" ? "Lighthouse"
: status.currentPhase === "finalizing" : status.currentPhase === "finalizing"
? "Finalizing" ? "Finalizing"
: (status.currentPhase ?? "Running"); : (status.currentPhase ?? "Running");
const progress = isPsiPhase ? psiProgress : crawlProgress; const progress = isLighthousePhase ? lighthouseProgress : crawlProgress;
const crawlProgressQuery = useQuery({ const crawlProgressQuery = useQuery({
queryKey: ["audit-crawl-progress", projectId, auditId], queryKey: ["audit-crawl-progress", projectId, auditId],
@ -240,7 +242,9 @@ function ProgressCard({
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h2 className="font-medium flex items-center gap-2"> <h2 className="font-medium flex items-center gap-2">
<Loader2 className="size-4 animate-spin text-primary" /> <Loader2 className="size-4 animate-spin text-primary" />
{isPsiPhase ? "Running PSI checks" : "Crawling pages"} {isLighthousePhase
? "Running Lighthouse checks"
: "Crawling pages"}
</h2> </h2>
<span className="badge badge-ghost badge-sm">{phaseLabel}</span> <span className="badge badge-ghost badge-sm">{phaseLabel}</span>
</div> </div>
@ -252,10 +256,12 @@ function ProgressCard({
/> />
<div className="flex items-center justify-between text-sm"> <div className="flex items-center justify-between text-sm">
{isPsiPhase ? ( {isLighthousePhase ? (
<span> <span>
{psiDone} / {status.psiTotal} checks {lighthouseDone} / {status.lighthouseTotal} checks
{status.psiFailed > 0 ? ` (${status.psiFailed} failed)` : ""} {status.lighthouseFailed > 0
? ` (${status.lighthouseFailed} failed)`
: ""}
</span> </span>
) : ( ) : (
<span> <span>

View File

@ -1,21 +1,21 @@
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { PsiIssuesScreen } from "@/client/features/psi/issues/PsiIssuesScreen"; import { LighthouseIssuesScreen } from "@/client/features/lighthouse/issues/LighthouseIssuesScreen";
import { psiIssuesSearchSchema } from "@/types/schemas/psi"; import { lighthouseIssuesSearchSchema } from "@/types/schemas/lighthouse";
export const Route = createFileRoute( export const Route = createFileRoute(
"/_project/p/$projectId/audit/issues/$resultId", "/_project/p/$projectId/audit/issues/$resultId",
)({ )({
validateSearch: psiIssuesSearchSchema, validateSearch: lighthouseIssuesSearchSchema,
component: AuditIssuesPage, component: AuditIssuesPage,
}); });
function AuditIssuesPage() { function AuditIssuesPage() {
const { projectId, resultId } = Route.useParams(); const { projectId, resultId } = Route.useParams();
const { category } = Route.useSearch(); const { auditId, category } = Route.useSearch();
const navigate = useNavigate({ from: Route.fullPath }); const navigate = useNavigate({ from: Route.fullPath });
return ( return (
<PsiIssuesScreen <LighthouseIssuesScreen
projectId={projectId} projectId={projectId}
resultId={resultId} resultId={resultId}
category={category} category={category}
@ -24,6 +24,7 @@ function AuditIssuesPage() {
void navigate({ void navigate({
to: "/p/$projectId/audit", to: "/p/$projectId/audit",
params: { projectId }, params: { projectId },
search: auditId ? { auditId } : undefined,
}) })
} }
onCategoryChange={(next) => onCategoryChange={(next) =>

View File

@ -1,13 +1,30 @@
/** /**
* Data access layer for site audit tables. * Data access layer for site audit tables.
* All D1 interactions for audits, audit_pages, and audit_psi_results. * All D1 interactions for audits, audit_pages, and stored Lighthouse results.
*/ */
import { db } from "@/db";
import { audits, auditPages, auditPsiResults } from "@/db/schema";
import { and, desc, eq } from "drizzle-orm"; import { and, desc, eq } from "drizzle-orm";
import type { PsiResult, AuditConfig } from "@/server/lib/audit/types"; import { db } from "@/db";
import { audits, auditLighthouseResults, auditPages } from "@/db/schema";
import type {
AuditConfig,
LighthouseResult,
StepPageResult,
} from "@/server/lib/audit/types";
// ─── Create ────────────────────────────────────────────────────────────────── const DB_BATCH_SIZE = 100;
type BatchStatement = Parameters<typeof db.batch>[0][number];
async function executeInBatches<T>(
items: T[],
buildStatement: (item: T) => BatchStatement,
) {
for (let i = 0; i < items.length; i += DB_BATCH_SIZE) {
const chunk = items.slice(i, i + DB_BATCH_SIZE).map(buildStatement);
const [first, ...rest] = chunk;
if (!first) continue;
await db.batch([first, ...rest]);
}
}
async function createAudit(data: { async function createAudit(data: {
id: string; id: string;
@ -17,7 +34,7 @@ async function createAudit(data: {
workflowInstanceId: string; workflowInstanceId: string;
config: AuditConfig; config: AuditConfig;
pagesTotal: number; pagesTotal: number;
psiTotal: number; lighthouseTotal: number;
}) { }) {
await db.insert(audits).values({ await db.insert(audits).values({
id: data.id, id: data.id,
@ -28,22 +45,20 @@ async function createAudit(data: {
config: JSON.stringify(data.config), config: JSON.stringify(data.config),
status: "running", status: "running",
pagesTotal: data.pagesTotal, pagesTotal: data.pagesTotal,
psiTotal: data.psiTotal, lighthouseTotal: data.lighthouseTotal,
currentPhase: "discovery", currentPhase: "discovery",
}); });
} }
// ─── Update ──────────────────────────────────────────────────────────────────
async function updateAuditProgress( async function updateAuditProgress(
auditId: string, auditId: string,
workflowInstanceId: string, workflowInstanceId: string,
data: { data: {
pagesCrawled?: number; pagesCrawled?: number;
pagesTotal?: number; pagesTotal?: number;
psiTotal?: number; lighthouseTotal?: number;
psiCompleted?: number; lighthouseCompleted?: number;
psiFailed?: number; lighthouseFailed?: number;
currentPhase?: string; currentPhase?: string;
}, },
) { ) {
@ -110,132 +125,70 @@ async function getAuditForWorkflow(
}); });
} }
// ─── Batch write results (finalize step) ─────────────────────────────────────
/**
* Use db.batch() to send individual INSERT statements in a single round-trip.
* D1's batch API supports up to 100 *statements* per call each statement
* has its own bind params, so there's no per-statement param limit issue.
*/
async function batchWriteResults( async function batchWriteResults(
auditId: string, auditId: string,
pages: Array<{ pages: StepPageResult[],
id: string; lighthouseResults: LighthouseResult[],
url: string;
statusCode: number;
redirectUrl: string | null;
title: string;
metaDescription: string;
canonicalUrl: string | null;
robotsMeta: string | null;
ogTitle: string | null;
ogDescription: string | null;
ogImage: string | null;
h1Count: number;
h2Count: number;
h3Count: number;
h4Count: number;
h5Count: number;
h6Count: number;
headingOrder: number[];
wordCount: number;
imagesTotal: number;
imagesMissingAlt: number;
images: Array<{ src: string | null; alt: string | null }>;
internalLinks: string[];
externalLinks: string[];
hasStructuredData: boolean;
hreflangTags: string[];
isIndexable: boolean;
responseTimeMs: number;
}>,
psiResults: PsiResult[],
) { ) {
const BATCH_SIZE = 100; // D1 max statements per batch() call await executeInBatches(pages, (page) =>
// ── Pages ──────────────────────────────────────────────────────────
const pageStatements = pages.map((p) =>
db.insert(auditPages).values({ db.insert(auditPages).values({
id: p.id, id: page.id,
auditId, auditId,
url: p.url, url: page.url,
statusCode: p.statusCode, statusCode: page.statusCode,
redirectUrl: p.redirectUrl, redirectUrl: page.redirectUrl,
// Metadata title: page.title,
title: p.title, metaDescription: page.metaDescription,
metaDescription: p.metaDescription, canonicalUrl: page.canonicalUrl,
canonicalUrl: p.canonicalUrl, robotsMeta: page.robotsMeta,
robotsMeta: p.robotsMeta, ogTitle: page.ogTitle,
// Open Graph ogDescription: page.ogDescription,
ogTitle: p.ogTitle, ogImage: page.ogImage,
ogDescription: p.ogDescription, h1Count: page.h1Count,
ogImage: p.ogImage, h2Count: page.h2Count,
// Headings h3Count: page.h3Count,
h1Count: p.h1Count, h4Count: page.h4Count,
h2Count: p.h2Count, h5Count: page.h5Count,
h3Count: p.h3Count, h6Count: page.h6Count,
h4Count: p.h4Count, headingOrderJson: JSON.stringify(page.headingOrder),
h5Count: p.h5Count, wordCount: page.wordCount,
h6Count: p.h6Count, imagesTotal: page.imagesTotal,
headingOrderJson: JSON.stringify(p.headingOrder), imagesMissingAlt: page.imagesMissingAlt,
// Content imagesJson: JSON.stringify(page.images),
wordCount: p.wordCount, internalLinkCount: page.internalLinks.length,
// Images externalLinkCount: page.externalLinks.length,
imagesTotal: p.imagesTotal, hasStructuredData: page.hasStructuredData,
imagesMissingAlt: p.imagesMissingAlt, hreflangTagsJson: JSON.stringify(page.hreflangTags),
imagesJson: JSON.stringify(p.images), isIndexable: page.isIndexable,
// Links responseTimeMs: page.responseTimeMs,
internalLinkCount: p.internalLinks.length,
externalLinkCount: p.externalLinks.length,
// Structured data
hasStructuredData: p.hasStructuredData,
// Hreflang
hreflangTagsJson: JSON.stringify(p.hreflangTags),
// Indexability
isIndexable: p.isIndexable,
// Performance
responseTimeMs: p.responseTimeMs,
}), }),
); );
for (let i = 0; i < pageStatements.length; i += BATCH_SIZE) { if (lighthouseResults.length === 0) {
const chunk = pageStatements.slice(i, i + BATCH_SIZE); return;
const [first, ...rest] = chunk;
await db.batch([first, ...rest]);
} }
// ── PSI results ──────────────────────────────────────────────────── await executeInBatches(lighthouseResults, (result) =>
if (psiResults.length > 0) { db.insert(auditLighthouseResults).values({
const psiStatements = psiResults.map((r) =>
db.insert(auditPsiResults).values({
id: crypto.randomUUID(), id: crypto.randomUUID(),
auditId, auditId,
pageId: r.pageId, pageId: result.pageId,
strategy: r.strategy, strategy: result.strategy,
performanceScore: r.performanceScore, performanceScore: result.performanceScore,
accessibilityScore: r.accessibilityScore, accessibilityScore: result.accessibilityScore,
bestPracticesScore: r.bestPracticesScore, bestPracticesScore: result.bestPracticesScore,
seoScore: r.seoScore, seoScore: result.seoScore,
lcpMs: r.lcpMs, lcpMs: result.lcpMs,
cls: r.cls, cls: result.cls,
inpMs: r.inpMs, inpMs: result.inpMs,
ttfbMs: r.ttfbMs, ttfbMs: result.ttfbMs,
errorMessage: r.errorMessage ?? null, errorMessage: result.errorMessage ?? null,
r2Key: r.r2Key ?? null, r2Key: result.r2Key ?? null,
payloadSizeBytes: r.payloadSizeBytes ?? null, payloadSizeBytes: result.payloadSizeBytes ?? null,
}), }),
); );
for (let i = 0; i < psiStatements.length; i += BATCH_SIZE) {
const chunk = psiStatements.slice(i, i + BATCH_SIZE);
const [first, ...rest] = chunk;
await db.batch([first, ...rest]);
}
}
} }
// ─── Read ────────────────────────────────────────────────────────────────────
async function getAuditForProject(auditId: string, projectId: string) { async function getAuditForProject(auditId: string, projectId: string) {
return db.query.audits.findFirst({ return db.query.audits.findFirst({
where: and(eq(audits.id, auditId), eq(audits.projectId, projectId)), where: and(eq(audits.id, auditId), eq(audits.projectId, projectId)),
@ -252,78 +205,80 @@ async function getAuditsByProject(projectId: string) {
return rows.map(({ audit }) => audit); return rows.map(({ audit }) => audit);
} }
async function getAuditResultsForProject(auditId: string, projectId: string) {
const audit = await getAuditForProject(auditId, projectId);
if (!audit) {
return { audit: null, pages: [], psi: [] };
}
const [pages, psi] = await Promise.all([
db.query.auditPages.findMany({
where: eq(auditPages.auditId, auditId),
}),
db.query.auditPsiResults.findMany({
where: eq(auditPsiResults.auditId, auditId),
}),
]);
return { audit, pages, psi };
}
async function getAuditCapacityUsageForUser(userId: string) { async function getAuditCapacityUsageForUser(userId: string) {
const rows = await db.query.audits.findMany({ const rows = await db.query.audits.findMany({
where: eq(audits.startedByUserId, userId), where: eq(audits.startedByUserId, userId),
columns: { columns: {
pagesTotal: true, pagesTotal: true,
psiTotal: true, lighthouseTotal: true,
}, },
}); });
return rows.reduce((total, row) => total + row.pagesTotal + row.psiTotal, 0); return rows.reduce(
(total, row) => total + row.pagesTotal + row.lighthouseTotal,
0,
);
} }
async function getPsiResultById(input: { async function getAuditResultsForProject(auditId: string, projectId: string) {
psiResultId: string; const audit = await getAuditForProject(auditId, projectId);
projectId: string; if (!audit) {
}) { return { audit: null, pages: [], lighthouse: [] };
const psi = await db.query.auditPsiResults.findFirst({
where: eq(auditPsiResults.id, input.psiResultId),
});
if (!psi) return null;
const parentAudit = await db.query.audits.findFirst({
where: and(
eq(audits.id, psi.auditId),
eq(audits.projectId, input.projectId),
),
});
if (!parentAudit) {
throw new Error("Audit not found");
} }
const page = await db.query.auditPages.findFirst({ const [pages, lighthouse] = await Promise.all([
where: eq(auditPages.id, psi.pageId), db.query.auditPages.findMany({
where: eq(auditPages.auditId, auditId),
}),
db.query.auditLighthouseResults.findMany({
where: eq(auditLighthouseResults.auditId, auditId),
}),
]);
return { audit, pages, lighthouse };
}
async function getLighthouseResultById(input: {
lighthouseResultId: string;
projectId: string;
}) {
const lighthouse = await db.query.auditLighthouseResults.findFirst({
where: eq(auditLighthouseResults.id, input.lighthouseResultId),
}); });
if (!lighthouse) {
return null;
}
const [parentAudit, page] = await Promise.all([
db.query.audits.findFirst({
where: and(
eq(audits.id, lighthouse.auditId),
eq(audits.projectId, input.projectId),
),
}),
db.query.auditPages.findFirst({
where: eq(auditPages.id, lighthouse.pageId),
}),
]);
if (!parentAudit) {
return null;
}
return { return {
psi, lighthouse,
page, page,
audit: parentAudit, audit: parentAudit,
}; };
} }
// ─── Delete ──────────────────────────────────────────────────────────────────
async function deleteAuditForProject(auditId: string, projectId: string) { async function deleteAuditForProject(auditId: string, projectId: string) {
await db await db
.delete(audits) .delete(audits)
.where(and(eq(audits.id, auditId), eq(audits.projectId, projectId))); .where(and(eq(audits.id, auditId), eq(audits.projectId, projectId)));
} }
// ─── Export ──────────────────────────────────────────────────────────────────
export const AuditRepository = { export const AuditRepository = {
createAudit, createAudit,
updateAuditProgress, updateAuditProgress,
@ -333,8 +288,8 @@ export const AuditRepository = {
batchWriteResults, batchWriteResults,
getAuditForProject, getAuditForProject,
getAuditsByProject, getAuditsByProject,
getAuditResultsForProject,
getAuditCapacityUsageForUser, getAuditCapacityUsageForUser,
getPsiResultById, getAuditResultsForProject,
getLighthouseResultById,
deleteAuditForProject, deleteAuditForProject,
} as const; } as const;

View File

@ -1,50 +1,33 @@
/**
* Business logic layer for site audits.
* Orchestrates between the workflow trigger, repository, and data formatting.
*/
import { env } from "cloudflare:workers"; import { env } from "cloudflare:workers";
import type { BillingCustomerContext } from "@/server/billing/subscription";
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository"; import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
import { AuditProgressKV } from "@/server/lib/audit/progress-kv";
import { normalizeAndValidateStartUrl } from "@/server/lib/audit/url-policy";
import { AppError } from "@/server/lib/errors";
import type { AuditConfig, PsiStrategy } from "@/server/lib/audit/types";
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
import { import {
MAX_USER_AUDIT_USAGE,
clampAuditMaxPages, clampAuditMaxPages,
getEstimatedAuditCapacity, getEstimatedAuditCapacity,
MAX_USER_AUDIT_USAGE,
} from "@/server/features/audit/services/audit-capacity"; } from "@/server/features/audit/services/audit-capacity";
import { jsonCodec } from "@/shared/json"; import { AppError } from "@/server/lib/errors";
import { z } from "zod"; import { AuditProgressKV } from "@/server/lib/audit/progress-kv";
import {
const auditConfigSchema = z.object({ parseAuditConfig,
maxPages: z.number().int().min(10).max(10_000), type AuditConfig,
psiStrategy: z.enum(["auto", "all", "manual", "none"]), type LighthouseStrategy,
psiApiKey: z.string().optional(), } from "@/server/lib/audit/types";
}); import { normalizeAndValidateStartUrl } from "@/server/lib/audit/url-policy";
const auditConfigCodec = jsonCodec(auditConfigSchema);
function parseAuditConfig(configRaw: string | null): AuditConfig | null {
if (!configRaw) return null;
const result = auditConfigCodec.safeParse(configRaw);
return result.success ? result.data : null;
}
async function startAudit(input: { async function startAudit(input: {
actorUserId: string; actorUserId: string;
billingCustomer: BillingCustomerContext;
projectId: string; projectId: string;
startUrl: string; startUrl: string;
maxPages?: number; maxPages?: number;
psiStrategy?: PsiStrategy; lighthouseStrategy?: LighthouseStrategy;
psiApiKey?: string;
}) { }) {
const maxPages = clampAuditMaxPages(input.maxPages); const maxPages = clampAuditMaxPages(input.maxPages);
const psiStrategy = input.psiStrategy ?? "auto"; const lighthouseStrategy = input.lighthouseStrategy ?? "auto";
const reservation = getEstimatedAuditCapacity({ const reservation = getEstimatedAuditCapacity({
maxPages, maxPages,
psiStrategy, lighthouseStrategy,
}); });
const currentUsage = await AuditRepository.getAuditCapacityUsageForUser( const currentUsage = await AuditRepository.getAuditCapacityUsageForUser(
@ -56,27 +39,7 @@ async function startAudit(input: {
} }
const auditId = crypto.randomUUID(); const auditId = crypto.randomUUID();
const config: AuditConfig = { maxPages, lighthouseStrategy };
const shouldRunPsi = psiStrategy !== "none";
let resolvedPsiApiKey = input.psiApiKey?.trim();
if (shouldRunPsi && !resolvedPsiApiKey) {
resolvedPsiApiKey =
(await ProjectRepository.getProjectPsiApiKey(input.projectId)) ??
undefined;
}
if (shouldRunPsi && !resolvedPsiApiKey) {
throw new Error("PSI API key is not set for this project.");
}
const config: AuditConfig = {
maxPages,
psiStrategy,
// PSI key is used for Google quota/abuse control (non-billing).
psiApiKey: resolvedPsiApiKey,
};
const startUrl = await normalizeAndValidateStartUrl(input.startUrl); const startUrl = await normalizeAndValidateStartUrl(input.startUrl);
await AuditRepository.createAudit({ await AuditRepository.createAudit({
@ -87,15 +50,15 @@ async function startAudit(input: {
workflowInstanceId: auditId, workflowInstanceId: auditId,
config, config,
pagesTotal: reservation.pagesTotal, pagesTotal: reservation.pagesTotal,
psiTotal: reservation.psiTotal, lighthouseTotal: reservation.lighthouseTotal,
}); });
// Trigger the Cloudflare Workflow
try { try {
await env.SITE_AUDIT_WORKFLOW.create({ await env.SITE_AUDIT_WORKFLOW.create({
id: auditId, id: auditId,
params: { params: {
auditId, auditId,
billingCustomer: input.billingCustomer,
projectId: input.projectId, projectId: input.projectId,
startUrl, startUrl,
config, config,
@ -108,6 +71,7 @@ async function startAudit(input: {
} catch { } catch {
// The workflow may never have been created, or may already be gone. // The workflow may never have been created, or may already be gone.
} }
await AuditRepository.deleteAuditForProject(auditId, input.projectId); await AuditRepository.deleteAuditForProject(auditId, input.projectId);
throw error; throw error;
} }
@ -125,9 +89,9 @@ async function getStatus(auditId: string, projectId: string) {
status: audit.status, status: audit.status,
pagesCrawled: audit.pagesCrawled, pagesCrawled: audit.pagesCrawled,
pagesTotal: audit.pagesTotal, pagesTotal: audit.pagesTotal,
psiTotal: audit.psiTotal, lighthouseTotal: audit.lighthouseTotal,
psiCompleted: audit.psiCompleted, lighthouseCompleted: audit.lighthouseCompleted,
psiFailed: audit.psiFailed, lighthouseFailed: audit.lighthouseFailed,
currentPhase: audit.currentPhase, currentPhase: audit.currentPhase,
startedAt: audit.startedAt, startedAt: audit.startedAt,
completedAt: audit.completedAt, completedAt: audit.completedAt,
@ -135,10 +99,8 @@ async function getStatus(auditId: string, projectId: string) {
} }
async function getResults(auditId: string, projectId: string) { async function getResults(auditId: string, projectId: string) {
const { audit, pages, psi } = await AuditRepository.getAuditResultsForProject( const { audit, pages, lighthouse } =
auditId, await AuditRepository.getAuditResultsForProject(auditId, projectId);
projectId,
);
if (!audit) throw new AppError("NOT_FOUND"); if (!audit) throw new AppError("NOT_FOUND");
@ -146,7 +108,6 @@ async function getResults(auditId: string, projectId: string) {
if (!parsedConfig) { if (!parsedConfig) {
throw new AppError("INTERNAL_ERROR", "Invalid audit configuration"); throw new AppError("INTERNAL_ERROR", "Invalid audit configuration");
} }
const { psiApiKey: _psiApiKey, ...safeConfig } = parsedConfig;
return { return {
audit: { audit: {
@ -157,31 +118,31 @@ async function getResults(auditId: string, projectId: string) {
pagesTotal: audit.pagesTotal, pagesTotal: audit.pagesTotal,
startedAt: audit.startedAt, startedAt: audit.startedAt,
completedAt: audit.completedAt, completedAt: audit.completedAt,
config: safeConfig, config: parsedConfig,
}, },
pages, pages,
psi, lighthouse,
}; };
} }
async function getHistory(projectId: string) { async function getHistory(projectId: string) {
const auditList = await AuditRepository.getAuditsByProject(projectId); const auditList = await AuditRepository.getAuditsByProject(projectId);
const didRunPsi = (configRaw: string | null) => { return auditList.map((audit) => {
const parsed = parseAuditConfig(configRaw); const parsedConfig = parseAuditConfig(audit.config);
return parsed?.psiStrategy != null && parsed.psiStrategy !== "none"; const ranLighthouse = parsedConfig?.lighthouseStrategy !== "none";
};
return auditList.map((a) => ({ return {
id: a.id, id: audit.id,
startUrl: a.startUrl, startUrl: audit.startUrl,
status: a.status, status: audit.status,
pagesCrawled: a.pagesCrawled, pagesCrawled: audit.pagesCrawled,
pagesTotal: a.pagesTotal, pagesTotal: audit.pagesTotal,
ranPsi: didRunPsi(a.config), ranLighthouse,
startedAt: a.startedAt, startedAt: audit.startedAt,
completedAt: a.completedAt, completedAt: audit.completedAt,
})); };
});
} }
async function getCrawlProgress(auditId: string, projectId: string) { async function getCrawlProgress(auditId: string, projectId: string) {
@ -189,6 +150,7 @@ async function getCrawlProgress(auditId: string, projectId: string) {
if (!audit) { if (!audit) {
throw new AppError("NOT_FOUND"); throw new AppError("NOT_FOUND");
} }
return AuditProgressKV.getCrawledUrls(auditId); return AuditProgressKV.getCrawledUrls(auditId);
} }
@ -197,6 +159,7 @@ async function remove(auditId: string, projectId: string) {
if (!audit) { if (!audit) {
throw new AppError("NOT_FOUND"); throw new AppError("NOT_FOUND");
} }
if (audit.status === "running") { if (audit.status === "running") {
if (!audit.workflowInstanceId) { if (!audit.workflowInstanceId) {
throw new AppError( throw new AppError(
@ -215,6 +178,7 @@ async function remove(auditId: string, projectId: string) {
throw new AppError("CONFLICT", "Unable to stop the running audit."); throw new AppError("CONFLICT", "Unable to stop the running audit.");
} }
} }
await AuditRepository.deleteAuditForProject(auditId, projectId); await AuditRepository.deleteAuditForProject(auditId, projectId);
} }

View File

@ -13,41 +13,46 @@ describe("audit capacity helpers", () => {
expect(clampAuditMaxPages(20_000)).toBe(10_000); expect(clampAuditMaxPages(20_000)).toBe(10_000);
}); });
it("estimates capacity for each psi strategy", () => { it("estimates capacity for each lighthouse strategy", () => {
expect( expect(
getEstimatedAuditCapacity({ maxPages: 100, psiStrategy: "none" }), getEstimatedAuditCapacity({ maxPages: 100, lighthouseStrategy: "none" }),
).toEqual({ ).toEqual({
pagesTotal: 100, pagesTotal: 100,
psiTotal: 0, lighthouseTotal: 0,
total: 100, total: 100,
}); });
expect( expect(
getEstimatedAuditCapacity({ maxPages: 100, psiStrategy: "manual" }), getEstimatedAuditCapacity({
maxPages: 100,
lighthouseStrategy: "manual",
}),
).toEqual({ ).toEqual({
pagesTotal: 100, pagesTotal: 100,
psiTotal: 0, lighthouseTotal: 0,
total: 100, total: 100,
}); });
expect( expect(
getEstimatedAuditCapacity({ maxPages: 100, psiStrategy: "auto" }), getEstimatedAuditCapacity({ maxPages: 100, lighthouseStrategy: "auto" }),
).toEqual({ ).toEqual({
pagesTotal: 100, pagesTotal: 100,
psiTotal: 20, lighthouseTotal: 20,
total: 120, total: 120,
}); });
expect( expect(
getEstimatedAuditCapacity({ maxPages: 100, psiStrategy: "all" }), getEstimatedAuditCapacity({ maxPages: 100, lighthouseStrategy: "all" }),
).toEqual({ ).toEqual({
pagesTotal: 100, pagesTotal: 100,
psiTotal: 200, lighthouseTotal: 200,
total: 300, total: 300,
}); });
}); });
it("stays within the global capacity limit for the maximum auto audit", () => { it("stays within the global capacity limit for the maximum auto audit", () => {
expect( expect(
getEstimatedAuditCapacity({ maxPages: 10_000, psiStrategy: "auto" }) getEstimatedAuditCapacity({
.total, maxPages: 10_000,
lighthouseStrategy: "auto",
}).total,
).toBeLessThan(MAX_USER_AUDIT_USAGE); ).toBeLessThan(MAX_USER_AUDIT_USAGE);
}); });
}); });

View File

@ -1,4 +1,4 @@
import type { PsiStrategy } from "@/server/lib/audit/types"; import type { LighthouseStrategy } from "@/server/lib/audit/types";
export const MAX_USER_AUDIT_USAGE = 100_000; export const MAX_USER_AUDIT_USAGE = 100_000;
@ -8,28 +8,28 @@ export function clampAuditMaxPages(maxPages?: number) {
export function getEstimatedAuditCapacity(input: { export function getEstimatedAuditCapacity(input: {
maxPages?: number; maxPages?: number;
psiStrategy?: PsiStrategy; lighthouseStrategy?: LighthouseStrategy;
}) { }) {
const pagesTotal = clampAuditMaxPages(input.maxPages); const pagesTotal = clampAuditMaxPages(input.maxPages);
const psiStrategy = input.psiStrategy ?? "auto"; const lighthouseStrategy = input.lighthouseStrategy ?? "auto";
let psiTotal = 0; let lighthouseChecks = 0;
switch (psiStrategy) { switch (lighthouseStrategy) {
case "all": case "all":
psiTotal = pagesTotal * 2; lighthouseChecks = pagesTotal * 2;
break; break;
case "auto": case "auto":
psiTotal = 20; lighthouseChecks = 20;
break; break;
case "manual": case "manual":
case "none": case "none":
psiTotal = 0; lighthouseChecks = 0;
break; break;
} }
return { return {
pagesTotal, pagesTotal,
psiTotal, lighthouseTotal: lighthouseChecks,
total: pagesTotal + psiTotal, total: pagesTotal + lighthouseChecks,
}; };
} }

View File

@ -0,0 +1,170 @@
import { z } from "zod";
import { describe, expect, it } from "vitest";
import { buildLighthouseExportFile } from "@/server/lib/lighthousePayload";
const storedPayloadJson = JSON.stringify({
version: 2,
source: "dataforseo-lighthouse",
hasIssueDetails: true,
metadata: {
requestedUrl: "https://everyapp.dev/blog/enable-mfa-rdp-ssh",
finalUrl: "https://everyapp.dev/blog/enable-mfa-rdp-ssh",
strategy: "mobile",
fetchedAt: "2026-03-23T19:27:33.000Z",
lighthouseVersion: "12.2.0",
taskId: "task-1",
cost: 0.00425,
},
scores: {
performance: 89,
accessibility: 93,
"best-practices": 92,
seo: 91,
},
metrics: {
firstContentfulPaint: {
score: 47,
displayValue: "3.1 s",
numericValue: 3100,
},
largestContentfulPaint: {
score: 12,
displayValue: "6.4 s",
numericValue: 6400,
},
totalBlockingTime: {
score: 79,
displayValue: "290 ms",
numericValue: 290,
},
cumulativeLayoutShift: {
score: 92,
displayValue: "0.03",
numericValue: 0.03,
},
speedIndex: {
score: 86,
displayValue: "3.7 s",
numericValue: 3700,
},
timeToInteractive: {
score: 13,
displayValue: "12.8 s",
numericValue: 12800,
},
interactionToNextPaint: {
score: null,
displayValue: null,
numericValue: null,
},
serverResponseTime: {
score: 90,
displayValue: "52 ms",
numericValue: 52,
},
},
issues: [
{
category: "performance",
auditKey: "unused-javascript",
title: "Reduce unused JavaScript",
description: "Trim dead code.",
score: 50,
scoreDisplayMode: "metricSavings",
displayValue: "Potential savings of 227 KiB",
impactMs: 0,
impactBytes: 232886,
severity: "critical",
items: [],
},
{
category: "accessibility",
auditKey: "color-contrast",
title:
"Background and foreground colors do not have a sufficient contrast ratio.",
description: "Improve contrast.",
score: 0,
scoreDisplayMode: "binary",
displayValue: null,
impactMs: null,
impactBytes: null,
severity: "critical",
items: [],
},
],
});
const issuesExportSchema = z.object({
resultId: z.string(),
category: z.string(),
issues: z.array(
z.object({
auditKey: z.string(),
category: z.string(),
}),
),
});
describe("buildLighthouseExportFile", () => {
it("exports the stored payload unchanged for full mode", () => {
const exported = buildLighthouseExportFile({
idField: "resultId",
idValue: "result-1",
finalUrl: "https://everyapp.dev/blog/enable-mfa-rdp-ssh",
strategy: "mobile",
createdAt: "2026-03-23T19:27:33.000Z",
payloadJson: storedPayloadJson,
mode: "full",
});
expect(exported.filename).toContain("-payload.json");
expect(exported.content).toBe(storedPayloadJson);
});
it("exports only actionable issues for issues mode", () => {
const exported = buildLighthouseExportFile({
idField: "resultId",
idValue: "result-1",
finalUrl: "https://everyapp.dev/blog/enable-mfa-rdp-ssh",
strategy: "mobile",
createdAt: "2026-03-23T19:27:33.000Z",
payloadJson: storedPayloadJson,
mode: "issues",
});
const content = issuesExportSchema.parse(JSON.parse(exported.content));
expect(exported.filename).toContain("-issues.json");
expect(content.resultId).toBe("result-1");
expect(content.category).toBe("all");
expect(content.issues.map((issue) => issue.auditKey)).toEqual([
"unused-javascript",
"color-contrast",
]);
expect(exported.content).not.toContain("timeToInteractive");
});
it("exports only the selected category for category mode", () => {
const exported = buildLighthouseExportFile({
idField: "resultId",
idValue: "result-1",
finalUrl: "https://everyapp.dev/blog/enable-mfa-rdp-ssh",
strategy: "mobile",
createdAt: "2026-03-23T19:27:33.000Z",
payloadJson: storedPayloadJson,
mode: "category",
category: "accessibility",
});
const content = issuesExportSchema.parse(JSON.parse(exported.content));
expect(exported.filename).toContain("-accessibility-issues.json");
expect(content.category).toBe("accessibility");
expect(content.issues).toEqual([
expect.objectContaining({
auditKey: "color-contrast",
category: "accessibility",
}),
]);
});
});

View File

@ -28,28 +28,6 @@ async function getProjectById(projectId: string) {
}); });
} }
async function getProjectPsiApiKey(projectId: string) {
const project = await db.query.projects.findFirst({
where: eq(projects.id, projectId),
columns: { pagespeedApiKey: true },
});
return project?.pagespeedApiKey ?? null;
}
async function setProjectPsiApiKey(projectId: string, apiKey: string) {
await db
.update(projects)
.set({ pagespeedApiKey: apiKey })
.where(eq(projects.id, projectId));
}
async function clearProjectPsiApiKey(projectId: string) {
await db
.update(projects)
.set({ pagespeedApiKey: null })
.where(eq(projects.id, projectId));
}
async function createProject( async function createProject(
organizationId: string, organizationId: string,
name: string, name: string,
@ -85,9 +63,6 @@ export const ProjectRepository = {
listProjects, listProjects,
getProjectForOrganization, getProjectForOrganization,
getProjectById, getProjectById,
getProjectPsiApiKey,
setProjectPsiApiKey,
clearProjectPsiApiKey,
createProject, createProject,
deleteProject, deleteProject,
} as const; } as const;

View File

@ -1,118 +0,0 @@
import { AppError } from "@/server/lib/errors";
import { getJsonFromR2 } from "@/server/lib/r2";
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
import {
PsiIssuesService,
type PsiIssueCategory,
} from "@/server/features/psi/services/PsiIssuesService";
import { buildPsiExportFile } from "@/server/features/psi/services/psi-export";
type PsiStrategy = "mobile" | "desktop";
type ExportMode = "full" | "issues" | "category";
type AuditPsiTarget = {
id: string;
strategy: PsiStrategy;
finalUrl: string;
createdAt: string;
r2Key: string | null;
};
async function getAuditPsiTarget(input: {
projectId: string;
resultId: string;
}): Promise<AuditPsiTarget> {
const site = await AuditRepository.getPsiResultById({
psiResultId: input.resultId,
projectId: input.projectId,
});
if (!site) {
throw new AppError("NOT_FOUND");
}
return {
id: site.psi.id,
strategy: site.psi.strategy,
finalUrl: site.page?.url ?? "",
createdAt: site.audit.startedAt,
r2Key: site.psi.r2Key,
};
}
async function getProjectPsiApiKey(input: { projectId: string }) {
const apiKey = await ProjectRepository.getProjectPsiApiKey(input.projectId);
return { apiKey };
}
async function saveProjectPsiApiKey(input: {
projectId: string;
apiKey: string;
}) {
await ProjectRepository.setProjectPsiApiKey(
input.projectId,
input.apiKey.trim(),
);
return { success: true };
}
async function clearProjectPsiApiKey(input: { projectId: string }) {
await ProjectRepository.clearProjectPsiApiKey(input.projectId);
return { success: true };
}
async function getAuditPsiIssues(input: {
projectId: string;
resultId: string;
category?: PsiIssueCategory;
}) {
const target = await getAuditPsiTarget(input);
if (!target.r2Key) {
throw new AppError("NOT_FOUND");
}
const payloadJson = await getJsonFromR2(target.r2Key);
const issues = PsiIssuesService.parseIssues(payloadJson, input.category);
return {
id: target.id,
finalUrl: target.finalUrl,
strategy: target.strategy,
createdAt: target.createdAt,
issues,
};
}
async function exportAuditPsi(input: {
projectId: string;
resultId: string;
mode: ExportMode;
category?: PsiIssueCategory;
}) {
const target = await getAuditPsiTarget(input);
if (!target.r2Key) {
throw new AppError("NOT_FOUND");
}
const payloadJson = await getJsonFromR2(target.r2Key);
return buildPsiExportFile({
idField: "resultId",
idValue: target.id,
finalUrl: target.finalUrl,
strategy: target.strategy,
createdAt: target.createdAt,
payloadJson,
mode: input.mode,
category: input.mode === "category" ? input.category : undefined,
});
}
export const PsiAuditService = {
getProjectPsiApiKey,
saveProjectPsiApiKey,
clearProjectPsiApiKey,
getAuditPsiIssues,
exportAuditPsi,
} as const;

View File

@ -1,230 +0,0 @@
import { sortBy } from "remeda";
import { z } from "zod";
import { jsonCodec } from "@/shared/json";
const PSI_CATEGORIES = [
"performance",
"accessibility",
"best-practices",
"seo",
] as const;
export type PsiIssueCategory = (typeof PSI_CATEGORIES)[number];
type PsiIssue = {
category: PsiIssueCategory;
auditKey: string;
title: string;
description: string;
score: number | null;
scoreDisplayMode: string | null;
displayValue: string | null;
impactMs: number | null;
impactBytes: number | null;
severity: "critical" | "warning" | "info";
items: string[];
};
type LighthouseAudit = {
title?: string;
description?: string;
score?: number | null;
scoreDisplayMode?: string;
displayValue?: string;
details?: {
overallSavingsMs?: number;
overallSavingsBytes?: number;
items?: Array<Record<string, unknown>>;
};
};
type LighthouseCategory = {
auditRefs?: Array<{
id?: string;
}>;
};
const lighthouseAuditSchema = z.object({
title: z.string().optional(),
description: z.string().optional(),
score: z.number().nullable().optional(),
scoreDisplayMode: z.string().optional(),
displayValue: z.string().optional(),
details: z
.object({
overallSavingsMs: z.number().optional(),
overallSavingsBytes: z.number().optional(),
items: z.array(z.record(z.string(), z.unknown())).optional(),
})
.optional(),
});
const lighthouseCategorySchema = z.object({
auditRefs: z
.array(
z.object({
id: z.string().optional(),
}),
)
.optional(),
});
const psiPayloadSchema = z.object({
lighthouseResult: z
.object({
audits: z
.record(z.string(), lighthouseAuditSchema)
.optional()
.default({}),
categories: z
.record(z.string(), lighthouseCategorySchema)
.optional()
.default({}),
})
.optional()
.default({
audits: {},
categories: {},
}),
});
const psiPayloadCodec = jsonCodec(psiPayloadSchema);
function normalizeScore(score: number | null | undefined): number | null {
if (score == null || Number.isNaN(score)) return null;
return Math.round(score * 100);
}
function compactItem(item: Record<string, unknown>): string {
const preferredKeys = [
"url",
"source",
"nodeLabel",
"snippet",
"totalBytes",
"wastedBytes",
"wastedMs",
"label",
"value",
];
const output: Record<string, unknown> = {};
for (const key of preferredKeys) {
if (item[key] != null) {
output[key] = item[key];
}
}
if (Object.keys(output).length === 0) {
for (const [key, value] of Object.entries(item).slice(0, 6)) {
output[key] = value;
}
}
return JSON.stringify(output);
}
function getSeverity(input: {
score: number | null;
impactMs: number | null;
impactBytes: number | null;
}): "critical" | "warning" | "info" {
if ((input.impactMs ?? 0) >= 300 || (input.impactBytes ?? 0) >= 150_000) {
return "critical";
}
if (input.score != null && input.score < 50) {
return "critical";
}
if ((input.impactMs ?? 0) >= 100 || (input.impactBytes ?? 0) >= 50_000) {
return "warning";
}
if (input.score != null && input.score < 90) {
return "warning";
}
return "info";
}
function parseIssues(
payloadJson: string,
categoryFilter?: PsiIssueCategory,
): PsiIssue[] {
const parsedPayload = psiPayloadCodec.safeParse(payloadJson);
if (!parsedPayload.success) {
throw new Error("Invalid Lighthouse payload JSON");
}
const audits: Record<string, LighthouseAudit> =
parsedPayload.data.lighthouseResult.audits;
const categories: Record<string, LighthouseCategory> =
parsedPayload.data.lighthouseResult.categories;
const issues: PsiIssue[] = [];
for (const category of PSI_CATEGORIES) {
if (categoryFilter && category !== categoryFilter) continue;
const refs = categories[category]?.auditRefs ?? [];
for (const ref of refs) {
const auditKey = ref.id;
if (!auditKey) continue;
const audit = audits[auditKey];
if (!audit) continue;
const score = normalizeScore(audit.score);
const displayMode = audit.scoreDisplayMode ?? null;
const isPass =
(score != null && score >= 90) ||
displayMode === "notApplicable" ||
displayMode === "informative" ||
displayMode === "manual";
if (isPass) continue;
const impactMs =
typeof audit.details?.overallSavingsMs === "number"
? audit.details.overallSavingsMs
: null;
const impactBytes =
typeof audit.details?.overallSavingsBytes === "number"
? audit.details.overallSavingsBytes
: null;
const items = Array.isArray(audit.details?.items)
? audit.details.items.slice(0, 10).map(compactItem)
: [];
issues.push({
category,
auditKey,
title: audit.title ?? auditKey,
description: audit.description ?? "",
score,
scoreDisplayMode: displayMode,
displayValue: audit.displayValue ?? null,
impactMs,
impactBytes,
severity: getSeverity({ score, impactMs, impactBytes }),
items,
});
}
}
return sortBy(
issues,
[
(issue) => (issue.impactMs ?? 0) * 1000 + (issue.impactBytes ?? 0),
"desc",
],
[(issue) => issue.score ?? 100, "asc"],
);
}
export const PsiIssuesService = {
parseIssues,
} as const;

View File

@ -1,52 +0,0 @@
import {
PsiIssuesService,
type PsiIssueCategory,
} from "@/server/features/psi/services/PsiIssuesService";
type PsiStrategy = "mobile" | "desktop";
type ExportMode = "full" | "issues" | "category";
export function buildPsiExportFile(input: {
idField: "auditId" | "resultId";
idValue: string;
finalUrl: string;
strategy: PsiStrategy;
createdAt: string;
payloadJson: string;
mode: ExportMode;
category?: PsiIssueCategory;
}) {
const safeDate = input.createdAt.replace(/[:.]/g, "-");
const baseName = `psi-${input.strategy}-${safeDate}`;
if (input.mode === "full") {
return {
filename: `${baseName}-full.json`,
content: input.payloadJson,
};
}
const issues = PsiIssuesService.parseIssues(
input.payloadJson,
input.category,
);
return {
filename:
input.mode === "category" && input.category
? `${baseName}-${input.category}-issues.json`
: `${baseName}-issues.json`,
content: JSON.stringify(
{
[input.idField]: input.idValue,
finalUrl: input.finalUrl,
strategy: input.strategy,
createdAt: input.createdAt,
category: input.category ?? "all",
issues,
},
null,
2,
),
};
}

View File

@ -0,0 +1,163 @@
import { detectUrlTemplate } from "./url-utils";
import type { BillingCustomerContext } from "@/server/billing/subscription";
import { createDataforseoClient } from "@/server/lib/dataforseoClient";
import type { LighthouseResult, LighthouseStrategy } from "./types";
import { putTextToR2 } from "@/server/lib/r2";
interface LighthouseSamplePage {
url: string;
statusCode: number;
}
type LighthouseFetchResult = {
result: LighthouseResult;
payloadJson: string | null;
};
async function fetchLighthouseResult(
url: string,
pageId: string,
strategy: "mobile" | "desktop",
billingCustomer: BillingCustomerContext,
): Promise<LighthouseFetchResult> {
let lastError: Error | null = null;
const dataforseo = createDataforseoClient(billingCustomer);
for (let attempt = 0; attempt < 3; attempt++) {
try {
if (attempt > 0) {
// Exponential backoff: 2s, 4s
await new Promise((resolve) =>
setTimeout(resolve, 2000 * Math.pow(2, attempt - 1)),
);
}
const data = await dataforseo.lighthouse.live({ url, strategy });
return {
result: {
url,
pageId,
strategy,
performanceScore: data.scores.performance,
accessibilityScore: data.scores.accessibility,
bestPracticesScore: data.scores["best-practices"],
seoScore: data.scores.seo,
lcpMs: data.metrics.largestContentfulPaint.numericValue,
cls: data.metrics.cumulativeLayoutShift.numericValue,
inpMs: data.metrics.interactionToNextPaint.numericValue,
ttfbMs: data.metrics.serverResponseTime.numericValue,
},
payloadJson: JSON.stringify(data),
};
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
console.warn(
`Lighthouse attempt ${attempt + 1} failed for ${url}:`,
lastError.message,
);
}
}
// All retries exhausted — return null scores
console.error(
`Lighthouse failed after 3 attempts for ${url}:`,
lastError?.message,
);
return {
result: {
url,
pageId,
strategy,
performanceScore: null,
accessibilityScore: null,
bestPracticesScore: null,
seoScore: null,
lcpMs: null,
cls: null,
inpMs: null,
ttfbMs: null,
errorMessage: lastError?.message ?? "Lighthouse request failed",
},
payloadJson: null,
};
}
export async function fetchAndStoreLighthouseResult(input: {
url: string;
pageId: string;
strategy: "mobile" | "desktop";
billingCustomer: BillingCustomerContext;
projectId: string;
auditId: string;
}): Promise<LighthouseResult> {
const fetched = await fetchLighthouseResult(
input.url,
input.pageId,
input.strategy,
input.billingCustomer,
);
if (!fetched.payloadJson) {
return fetched.result;
}
const key = `site-audit/${input.projectId}/${input.auditId}/${input.pageId}-${input.strategy}.json`;
const uploaded = await putTextToR2(key, fetched.payloadJson);
return {
...fetched.result,
r2Key: uploaded.key,
payloadSizeBytes: uploaded.sizeBytes,
};
}
/**
* Select which pages to run Lighthouse on, based on the chosen strategy.
*/
export function selectLighthouseSample(
pages: LighthouseSamplePage[],
startUrl: string,
strategy: LighthouseStrategy,
): string[] {
if (strategy === "none") return [];
// Only consider pages that loaded successfully
const validPages = pages.filter(
(p) => p.statusCode >= 200 && p.statusCode < 300,
);
if (strategy === "all") {
return validPages.map((p) => p.url);
}
if (strategy === "manual") {
// manual = user picks after crawl; for now return empty
return [];
}
// strategy === "auto": homepage + 1 per URL pattern, capped at 10
const selected = new Set<string>();
// Always include the start URL / homepage
const startPage = validPages.find((p) => p.url === startUrl);
if (startPage) selected.add(startPage.url);
// Group by URL template pattern
const templateGroups = new Map<string, LighthouseSamplePage>();
for (const page of validPages) {
if (selected.has(page.url)) continue;
const template = detectUrlTemplate(new URL(page.url).pathname);
if (!templateGroups.has(template)) {
templateGroups.set(template, page);
}
}
// Add one page per template group
for (const [, page] of templateGroups) {
if (selected.size >= 10) break;
selected.add(page.url);
}
return Array.from(selected);
}

View File

@ -1,176 +0,0 @@
/**
* Google PageSpeed Insights (PSI) API client and sampling logic.
*/
import { detectUrlTemplate } from "./url-utils";
import type { PsiResult, PsiStrategy } from "./types";
interface PsiSamplePage {
url: string;
statusCode: number;
}
const PSI_API_URL =
"https://www.googleapis.com/pagespeedonline/v5/runPagespeed";
/**
* Fetch PageSpeed Insights results for a single URL.
* Retries up to 3 times with exponential backoff.
*/
export async function fetchPsiResult(
url: string,
pageId: string,
strategy: "mobile" | "desktop",
apiKey: string,
): Promise<PsiResult> {
// Build URL with multiple category params (PSI API allows repeated 'category')
const apiUrl = `${PSI_API_URL}?url=${encodeURIComponent(url)}&strategy=${strategy}&key=${encodeURIComponent(apiKey)}&category=performance&category=accessibility&category=best-practices&category=seo`;
let lastError: Error | null = null;
for (let attempt = 0; attempt < 3; attempt++) {
try {
if (attempt > 0) {
// Exponential backoff: 2s, 4s
await new Promise((resolve) =>
setTimeout(resolve, 2000 * Math.pow(2, attempt - 1)),
);
}
const response = await fetch(apiUrl, {
signal: AbortSignal.timeout(60_000), // PSI can be slow
});
if (!response.ok) {
const text = await response.text();
throw new Error(`PSI API ${response.status}: ${text.slice(0, 200)}`);
}
const data: PsiApiResponse = await response.json();
return parsePsiResponse(data, url, pageId, strategy);
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
console.warn(
`PSI attempt ${attempt + 1} failed for ${url}:`,
lastError.message,
);
}
}
// All retries exhausted — return null scores
console.error(`PSI failed after 3 attempts for ${url}:`, lastError?.message);
return {
url,
pageId,
strategy,
performanceScore: null,
accessibilityScore: null,
bestPracticesScore: null,
seoScore: null,
lcpMs: null,
cls: null,
inpMs: null,
ttfbMs: null,
errorMessage: lastError?.message ?? "PSI request failed",
};
}
/**
* Select which pages to run PSI on, based on the chosen strategy.
*/
export function selectPsiSample(
pages: PsiSamplePage[],
startUrl: string,
strategy: PsiStrategy,
): string[] {
if (strategy === "none") return [];
// Only consider pages that loaded successfully
const validPages = pages.filter(
(p) => p.statusCode >= 200 && p.statusCode < 300,
);
if (strategy === "all") {
return validPages.map((p) => p.url);
}
if (strategy === "manual") {
// manual = user picks after crawl; for now return empty
return [];
}
// strategy === "auto": homepage + 1 per URL pattern, capped at 10
const selected = new Set<string>();
// Always include the start URL / homepage
const startPage = validPages.find((p) => p.url === startUrl);
if (startPage) selected.add(startPage.url);
// Group by URL template pattern
const templateGroups = new Map<string, PsiSamplePage>();
for (const page of validPages) {
if (selected.has(page.url)) continue;
const template = detectUrlTemplate(new URL(page.url).pathname);
if (!templateGroups.has(template)) {
templateGroups.set(template, page);
}
}
// Add one page per template group
for (const [, page] of templateGroups) {
if (selected.size >= 10) break;
selected.add(page.url);
}
return Array.from(selected);
}
// ─── PSI API Response Types ──────────────────────────────────────────────────
interface PsiApiResponse {
lighthouseResult?: {
categories?: {
performance?: { score?: number | null };
accessibility?: { score?: number | null };
"best-practices"?: { score?: number | null };
seo?: { score?: number | null };
};
audits?: {
"largest-contentful-paint"?: { numericValue?: number };
"cumulative-layout-shift"?: { numericValue?: number };
"interaction-to-next-paint"?: { numericValue?: number };
"server-response-time"?: { numericValue?: number };
};
};
}
function parsePsiResponse(
data: PsiApiResponse,
url: string,
pageId: string,
strategy: "mobile" | "desktop",
): PsiResult {
const categories = data.lighthouseResult?.categories;
const audits = data.lighthouseResult?.audits;
return {
url,
pageId,
strategy,
performanceScore: scoreToPercent(categories?.performance?.score),
accessibilityScore: scoreToPercent(categories?.accessibility?.score),
bestPracticesScore: scoreToPercent(categories?.["best-practices"]?.score),
seoScore: scoreToPercent(categories?.seo?.score),
lcpMs: audits?.["largest-contentful-paint"]?.numericValue ?? null,
cls: audits?.["cumulative-layout-shift"]?.numericValue ?? null,
inpMs: audits?.["interaction-to-next-paint"]?.numericValue ?? null,
ttfbMs: audits?.["server-response-time"]?.numericValue ?? null,
rawPayloadJson: JSON.stringify(data),
};
}
/** PSI scores come as 0-1 floats; convert to 0-100 integers. */
function scoreToPercent(score: number | null | undefined): number | null {
if (score == null) return null;
return Math.round(score * 100);
}

View File

@ -2,12 +2,27 @@
* Shared types for the site audit system. * Shared types for the site audit system.
*/ */
export type PsiStrategy = "auto" | "all" | "manual" | "none"; import { z } from "zod";
import { jsonCodec } from "@/shared/json";
export type LighthouseStrategy = "auto" | "all" | "manual" | "none";
export interface AuditConfig { export interface AuditConfig {
maxPages: number; maxPages: number;
psiStrategy: PsiStrategy; lighthouseStrategy: LighthouseStrategy;
psiApiKey?: string; }
const auditConfigSchema = z.object({
maxPages: z.number().int().min(10).max(10_000),
lighthouseStrategy: z.enum(["auto", "all", "manual", "none"]),
});
const auditConfigCodec = jsonCodec(auditConfigSchema);
export function parseAuditConfig(configRaw: string | null): AuditConfig | null {
if (!configRaw) return null;
const result = auditConfigCodec.safeParse(configRaw);
return result.success ? result.data : null;
} }
/** Data extracted from a single page via cheerio. */ /** Data extracted from a single page via cheerio. */
@ -47,8 +62,8 @@ export interface PageAnalysis {
hreflangTags: string[]; hreflangTags: string[];
} }
/** PSI result for a single URL+strategy. */ /** Lighthouse result for a single URL+strategy. */
export interface PsiResult { export interface LighthouseResult {
url: string; url: string;
pageId: string; pageId: string;
strategy: "mobile" | "desktop"; strategy: "mobile" | "desktop";
@ -63,5 +78,35 @@ export interface PsiResult {
errorMessage?: string | null; errorMessage?: string | null;
r2Key?: string | null; r2Key?: string | null;
payloadSizeBytes?: number | null; payloadSizeBytes?: number | null;
rawPayloadJson?: string | null; }
export interface StepPageResult {
id: string;
url: string;
statusCode: number;
redirectUrl: string | null;
title: string;
metaDescription: string;
canonicalUrl: string | null;
robotsMeta: string | null;
ogTitle: string | null;
ogDescription: string | null;
ogImage: string | null;
h1Count: number;
h2Count: number;
h3Count: number;
h4Count: number;
h5Count: number;
h6Count: number;
headingOrder: number[];
wordCount: number;
imagesTotal: number;
imagesMissingAlt: number;
images: Array<{ src: string | null; alt: string | null }>;
internalLinks: string[];
externalLinks: string[];
hasStructuredData: boolean;
hreflangTags: string[];
isIndexable: boolean;
responseTimeMs: number;
} }

View File

@ -18,6 +18,9 @@ import {
type LabsKeywordDataItem, type LabsKeywordDataItem,
type SerpLiveItem, type SerpLiveItem,
} from "@/server/lib/dataforseo"; } from "@/server/lib/dataforseo";
import { fetchDataforseoLighthouseResultRaw } from "@/server/lib/dataforseoLighthouse";
import type { LighthouseStrategy } from "@/server/lib/dataforseoLighthousePayload";
import type { StoredLighthousePayload } from "@/server/lib/lighthouseStoredPayload";
import { import {
fetchBacklinksRowsRaw, fetchBacklinksRowsRaw,
fetchBacklinksSummaryRaw, fetchBacklinksSummaryRaw,
@ -166,6 +169,13 @@ export function createDataforseoClient(customer: BillingCustomerContext) {
); );
}, },
}, },
lighthouse: {
live(input: { url: string; strategy: LighthouseStrategy }) {
return meterDataforseoCall<StoredLighthousePayload>(customer, () =>
fetchDataforseoLighthouseResultRaw(input),
);
},
},
} as const; } as const;
} }

View File

@ -0,0 +1,60 @@
import { env } from "cloudflare:workers";
import {
parseDataforseoLighthousePayload,
requestCategories,
type LighthouseStrategy,
} from "@/server/lib/dataforseoLighthousePayload";
import type { DataforseoApiResponse } from "@/server/lib/dataforseoCost";
import type { StoredLighthousePayload } from "@/server/lib/lighthouseStoredPayload";
const DATAFORSEO_LIGHTHOUSE_ENDPOINT =
"https://api.dataforseo.com/v3/on_page/lighthouse/live/json";
export async function fetchDataforseoLighthouseResultRaw(input: {
url: string;
strategy: LighthouseStrategy;
}): Promise<DataforseoApiResponse<StoredLighthousePayload>> {
const response = await fetch(DATAFORSEO_LIGHTHOUSE_ENDPOINT, {
method: "POST",
headers: {
Authorization: `Basic ${env.DATAFORSEO_API_KEY?.trim() ?? ""}`,
"Content-Type": "application/json",
},
body: JSON.stringify([
{
url: input.url,
for_mobile: input.strategy === "mobile",
categories: requestCategories,
},
]),
signal: AbortSignal.timeout(60_000),
});
const rawText = await response.text();
if (!response.ok) {
throw new Error(
`DataForSEO Lighthouse request failed (${response.status}): ${rawText}`,
);
}
let payload: unknown;
try {
payload = JSON.parse(rawText);
} catch {
throw new Error(
`DataForSEO Lighthouse returned non-JSON content (content-type: ${response.headers.get("content-type") ?? "unknown"}): ${rawText}`,
);
}
const data = parseDataforseoLighthousePayload(payload, input);
return {
data,
billing: {
path: ["v3", "on_page", "lighthouse", "live", "json"],
costUsd: data.metadata.cost ?? 0,
resultCount: 1,
},
};
}

View File

@ -0,0 +1,250 @@
import { describe, expect, it } from "vitest";
import { parseDataforseoLighthousePayload } from "@/server/lib/dataforseoLighthousePayload";
import { readStoredLighthousePayload } from "@/server/lib/lighthousePayload";
describe("parseDataforseoLighthousePayload", () => {
it("stores only issue-level lighthouse data and key metadata", () => {
const parsed = parseDataforseoLighthousePayload(
{
status_code: 20000,
status_message: "Ok.",
tasks: [
{
id: "task-1",
status_code: 20000,
status_message: "Ok.",
cost: 0.00425,
result: [
{
requestedUrl: "https://everyapp.dev/",
finalUrl: "https://everyapp.dev/",
lighthouseVersion: "12.2.0",
categories: {
performance: {
score: 0.54,
auditRefs: [{ id: "unused-javascript" }],
},
accessibility: {
score: 0.93,
auditRefs: [{ id: "accesskeys" }],
},
"best-practices": { score: 0.79, auditRefs: [] },
seo: { score: 0.92, auditRefs: [] },
},
audits: {
"unused-javascript": {
title: "Reduce unused JavaScript",
description: "Trim dead code.",
score: 0,
scoreDisplayMode: "metricSavings",
displayValue: "Potential savings of 188 KiB",
numericValue: 193002,
details: {
overallSavingsMs: 1270,
overallSavingsBytes: 193002,
items: [
{
url: "https://cdn.example.com/app.js",
wastedBytes: 193002,
},
],
},
},
accesskeys: {
title: "`[accesskey]` values are unique",
description: "Access keys should not conflict.",
score: null,
scoreDisplayMode: "error",
},
interactive: {
title: "Time to Interactive",
description: "Time until the page becomes interactive.",
score: 0.13,
scoreDisplayMode: "numeric",
displayValue: "12.8 s",
numericValue: 12800,
},
},
},
],
},
],
},
{
url: "https://everyapp.dev/",
strategy: "mobile",
},
);
const { report } = readStoredLighthousePayload(JSON.stringify(parsed));
expect(parsed.metrics.timeToInteractive.displayValue).toBe("12.8 s");
expect(parsed).toMatchObject({
version: 2,
source: "dataforseo-lighthouse",
hasIssueDetails: true,
metadata: {
requestedUrl: "https://everyapp.dev/",
finalUrl: "https://everyapp.dev/",
strategy: "mobile",
lighthouseVersion: "12.2.0",
taskId: "task-1",
cost: 0.00425,
},
scores: {
performance: 54,
accessibility: 93,
"best-practices": 79,
seo: 92,
},
metrics: {
timeToInteractive: {
score: 13,
displayValue: "12.8 s",
numericValue: 12800,
},
},
});
expect(parsed.issues).toHaveLength(1);
expect(parsed.issues).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ auditKey: "interactive" }),
]),
);
expect(parsed).not.toHaveProperty("lighthouseResult");
expect(report.hasIssueDetails).toBe(true);
expect(report.issues).toEqual([
expect.objectContaining({
auditKey: "unused-javascript",
category: "performance",
impactMs: 1270,
impactBytes: 193002,
title: "Reduce unused JavaScript",
}),
]);
});
it("throws when the lighthouse response has no category scores", () => {
expect(() =>
parseDataforseoLighthousePayload(
{
status_code: 20000,
status_message: "Ok.",
tasks: [
{
id: "task-1",
status_code: 20000,
status_message: "Ok.",
cost: 0.00425,
result: [
{
requestedUrl:
"https://everyapp.dev/blog/category/cyber-security",
finalUrl:
"https://everyapp.dev/blog/category/cyber-security/",
lighthouseVersion: "12.2.0",
categories: {
performance: { score: null, auditRefs: [] },
accessibility: { score: null, auditRefs: [] },
"best-practices": { score: null, auditRefs: [] },
seo: { score: null, auditRefs: [] },
},
audits: {},
},
],
},
],
},
{
url: "https://everyapp.dev/blog/category/cyber-security",
strategy: "desktop",
},
),
).toThrow("DataForSEO Lighthouse returned no category scores");
});
it("throws when DataForSEO returns a non-success task status", () => {
expect(() =>
parseDataforseoLighthousePayload(
{
status_code: 20000,
status_message: "Ok.",
tasks: [
{
id: "task-1",
status_code: 40501,
status_message: "Insufficient credits",
result: [],
},
],
},
{
url: "https://everyapp.dev/",
strategy: "mobile",
},
),
).toThrow("Insufficient credits");
});
it("includes schema details when the payload shape is invalid", () => {
expect(() =>
parseDataforseoLighthousePayload(null, {
url: "https://everyapp.dev/",
strategy: "mobile",
}),
).toThrow("<root>");
});
it("accepts audits whose details.items is an object", () => {
expect(() =>
parseDataforseoLighthousePayload(
{
status_code: 20000,
status_message: "Ok.",
tasks: [
{
id: "task-1",
status_code: 20000,
status_message: "Ok.",
cost: 0.00425,
result: [
{
requestedUrl: "https://everyapp.dev/",
finalUrl: "https://everyapp.dev/",
lighthouseVersion: "12.2.0",
categories: {
performance: {
score: 0.54,
auditRefs: [{ id: "document-latency-insight" }],
},
accessibility: { score: 0.93, auditRefs: [] },
"best-practices": { score: 0.79, auditRefs: [] },
seo: { score: 0.92, auditRefs: [] },
},
audits: {
"document-latency-insight": {
title: "Document request latency",
description: "Latency insight.",
score: 0,
scoreDisplayMode: "informative",
details: {
items: {
latencyMs: 120,
},
},
},
},
},
],
},
],
},
{
url: "https://everyapp.dev/",
strategy: "mobile",
},
),
).not.toThrow();
});
});

View File

@ -0,0 +1,172 @@
import { z } from "zod";
import {
buildStoredLighthouseIssues,
buildStoredLighthouseMetrics,
type RawLighthouseAudit,
type RawLighthouseCategory,
scoreToPercent,
type StoredLighthousePayload,
} from "@/server/lib/lighthouseStoredPayload";
export const requestCategories = [
"performance",
"accessibility",
"best_practices",
"seo",
] as const;
export type LighthouseStrategy = "mobile" | "desktop";
const lighthouseAuditItemsSchema = z
.union([
z.array(z.record(z.string(), z.unknown())),
z.record(z.string(), z.unknown()),
])
.transform((items) => (Array.isArray(items) ? items : [items]));
const lighthouseAuditSchema = z
.object({
score: z.number().nullable().optional(),
displayValue: z.string().optional(),
numericValue: z.number().optional(),
title: z.string().optional(),
description: z.string().optional(),
scoreDisplayMode: z.string().optional(),
details: z
.object({
overallSavingsMs: z.number().optional(),
overallSavingsBytes: z.number().optional(),
items: lighthouseAuditItemsSchema.optional(),
})
.passthrough()
.optional(),
})
.passthrough();
const lighthouseCategorySchema = z
.object({
score: z.number().nullable().optional(),
auditRefs: z
.array(
z
.object({
id: z.string().optional(),
})
.passthrough(),
)
.optional(),
})
.passthrough();
const lighthouseResponseSchema = z
.object({
requestedUrl: z.string().optional(),
finalUrl: z.string().optional(),
lighthouseVersion: z.string().optional(),
categories: z
.record(z.string(), lighthouseCategorySchema)
.optional()
.default({}),
audits: z.record(z.string(), lighthouseAuditSchema).optional().default({}),
})
.passthrough();
const dataforseoTaskSchema = z
.object({
id: z.string().optional(),
cost: z.number().optional(),
status_code: z.number().optional(),
status_message: z.string().optional(),
result: z.array(lighthouseResponseSchema).optional(),
})
.passthrough();
const dataforseoLighthouseResponseSchema = z
.object({
status_code: z.number().optional(),
status_message: z.string().optional(),
tasks: z.array(dataforseoTaskSchema).optional(),
})
.passthrough();
function summarizeZodIssues(error: z.ZodError, maxIssues = 3): string {
return error.issues
.slice(0, maxIssues)
.map((issue) => {
const path = issue.path.length > 0 ? issue.path.join(".") : "<root>";
return `${path}: ${issue.message}`;
})
.join("; ");
}
export function parseDataforseoLighthousePayload(
payload: unknown,
input: { url: string; strategy: LighthouseStrategy },
): StoredLighthousePayload {
const parsed = dataforseoLighthouseResponseSchema.safeParse(payload);
if (!parsed.success) {
throw new Error(
`DataForSEO Lighthouse returned an invalid response: ${summarizeZodIssues(parsed.error)}`,
);
}
if (parsed.data.status_code !== 20000) {
throw new Error(
parsed.data.status_message ?? "DataForSEO Lighthouse request failed",
);
}
const task = parsed.data.tasks?.[0];
if (!task) {
throw new Error("DataForSEO Lighthouse response missing task");
}
if (task.status_code !== 20000) {
throw new Error(task.status_message ?? "DataForSEO Lighthouse task failed");
}
const result = task.result?.[0];
if (!result) {
throw new Error("DataForSEO Lighthouse response missing result");
}
const fetchedAt = new Date().toISOString();
const categories: Record<string, RawLighthouseCategory> =
result.categories ?? {};
const audits: Record<string, RawLighthouseAudit> = result.audits ?? {};
const issueReport = buildStoredLighthouseIssues({ audits, categories });
const metrics = buildStoredLighthouseMetrics({ audits });
const storedPayload: StoredLighthousePayload = {
version: 2,
source: "dataforseo-lighthouse",
hasIssueDetails: issueReport.hasIssueDetails,
metadata: {
requestedUrl: result.requestedUrl ?? input.url,
finalUrl: result.finalUrl ?? input.url,
strategy: input.strategy,
fetchedAt,
lighthouseVersion: result.lighthouseVersion ?? null,
taskId: task.id ?? null,
cost: task.cost ?? null,
},
scores: {
performance: scoreToPercent(categories.performance?.score),
accessibility: scoreToPercent(categories.accessibility?.score),
"best-practices": scoreToPercent(categories["best-practices"]?.score),
seo: scoreToPercent(categories.seo?.score),
},
metrics,
issues: issueReport.issues,
};
const allScoresMissing = Object.values(storedPayload.scores).every(
(score) => score == null,
);
if (allScoresMissing) {
throw new Error(
`DataForSEO Lighthouse returned no category scores for ${storedPayload.metadata.finalUrl}`,
);
}
return storedPayload;
}

View File

@ -0,0 +1,123 @@
import { sortBy } from "remeda";
import type { LighthouseCategory } from "@/shared/lighthouse";
import { jsonCodec } from "@/shared/json";
import {
storedLighthousePayloadSchema,
type StoredLighthouseIssue,
type StoredLighthousePayload,
} from "@/server/lib/lighthouseStoredPayload";
const storedPayloadCodec = jsonCodec(storedLighthousePayloadSchema);
type ExportMode = "full" | "issues" | "category";
type LighthouseIssueReport = {
issues: StoredLighthouseIssue[];
hasIssueDetails: boolean;
};
function sortIssues(issues: StoredLighthouseIssue[]) {
return sortBy(
issues,
[
(issue) => (issue.impactMs ?? 0) * 1000 + (issue.impactBytes ?? 0),
"desc",
],
[(issue) => issue.score ?? 100, "asc"],
);
}
function parseStoredLighthousePayload(
payloadJson: string,
): StoredLighthousePayload | null {
const storedPayload = storedPayloadCodec.safeParse(payloadJson);
if (storedPayload.success) {
return storedPayload.data;
}
try {
JSON.parse(payloadJson);
} catch {
throw new Error("Invalid Lighthouse payload JSON");
}
return null;
}
function buildLighthouseIssueReport(
storedPayload: StoredLighthousePayload | null,
categoryFilter?: LighthouseCategory,
): LighthouseIssueReport {
if (!storedPayload) {
return {
hasIssueDetails: false,
issues: [],
};
}
const filteredIssues = categoryFilter
? storedPayload.issues.filter((issue) => issue.category === categoryFilter)
: storedPayload.issues;
return {
hasIssueDetails: storedPayload.hasIssueDetails,
issues: sortIssues(filteredIssues),
};
}
export function readStoredLighthousePayload(
payloadJson: string,
categoryFilter?: LighthouseCategory,
) {
const storedPayload = parseStoredLighthousePayload(payloadJson);
return {
storedPayload,
report: buildLighthouseIssueReport(storedPayload, categoryFilter),
};
}
export function buildLighthouseExportFile(input: {
idField: "auditId" | "resultId";
idValue: string;
finalUrl: string;
strategy: "mobile" | "desktop";
createdAt: string;
payloadJson: string;
mode: ExportMode;
category?: LighthouseCategory;
}) {
const safeDate = input.createdAt.replace(/[:.]/g, "-");
const baseName = `lighthouse-${input.strategy}-${safeDate}`;
if (input.mode === "full") {
return {
filename: `${baseName}-payload.json`,
content: input.payloadJson,
};
}
const { report } = readStoredLighthousePayload(
input.payloadJson,
input.category,
);
return {
filename:
input.mode === "category" && input.category
? `${baseName}-${input.category}-issues.json`
: `${baseName}-issues.json`,
content: JSON.stringify(
{
[input.idField]: input.idValue,
finalUrl: input.finalUrl,
strategy: input.strategy,
createdAt: input.createdAt,
category: input.category ?? "all",
issues: report.issues,
},
null,
2,
),
};
}

View File

@ -0,0 +1,159 @@
import { describe, expect, it } from "vitest";
import {
buildStoredLighthouseIssues,
buildStoredLighthouseMetrics,
} from "@/server/lib/lighthouseStoredPayload";
describe("lighthouse stored payload classification", () => {
it("keeps actionable audits but separates metrics and diagnostics", () => {
const audits = {
interactive: {
title: "Time to Interactive",
score: 0.13,
scoreDisplayMode: "numeric",
displayValue: "12.8 s",
numericValue: 12800,
},
"largest-contentful-paint-element": {
title: "Largest Contentful Paint element",
score: 0,
scoreDisplayMode: "metricSavings",
displayValue: "3,630 ms",
},
"unused-javascript": {
title: "Reduce unused JavaScript",
description: "Trim dead code.",
score: 0.5,
scoreDisplayMode: "metricSavings",
displayValue: "Potential savings of 227 KiB",
details: {
overallSavingsBytes: 232886,
},
},
"color-contrast": {
title:
"Background and foreground colors do not have a sufficient contrast ratio.",
description: "Improve contrast.",
score: 0,
scoreDisplayMode: "binary",
},
};
const categories = {
performance: {
auditRefs: [
{ id: "interactive" },
{ id: "largest-contentful-paint-element" },
{ id: "unused-javascript" },
],
},
accessibility: {
auditRefs: [{ id: "color-contrast" }],
},
"best-practices": { auditRefs: [] },
seo: { auditRefs: [] },
};
const issues = buildStoredLighthouseIssues({ audits, categories });
const metrics = buildStoredLighthouseMetrics({ audits });
expect(issues.issues.map((issue) => issue.auditKey)).toEqual([
"unused-javascript",
"color-contrast",
]);
expect(metrics.timeToInteractive.displayValue).toBe("12.8 s");
expect(metrics.timeToInteractive.score).toBe(13);
});
it("skips passing and non-actionable audits even when they appear in audit refs", () => {
const audits = {
passBinary: {
title: "Serve images in next-gen formats",
score: 1,
scoreDisplayMode: "binary",
},
informative: {
title: "User Timing marks and measures",
score: 0,
scoreDisplayMode: "informative",
},
manual: {
title: "Structured data is valid",
score: 0,
scoreDisplayMode: "manual",
},
notApplicable: {
title: "Uses optimized images",
score: 0,
scoreDisplayMode: "notApplicable",
},
errorAudit: {
title: "`[accesskey]` values are unique",
score: null,
scoreDisplayMode: "error",
},
goodScore: {
title: "Reduce unused CSS",
score: 0.96,
scoreDisplayMode: "metricSavings",
},
};
const categories = {
performance: {
auditRefs: [
{ id: "passBinary" },
{ id: "informative" },
{ id: "manual" },
{ id: "notApplicable" },
{ id: "goodScore" },
],
},
accessibility: {
auditRefs: [{ id: "errorAudit" }],
},
"best-practices": { auditRefs: [] },
seo: { auditRefs: [] },
};
const issues = buildStoredLighthouseIssues({ audits, categories });
expect(issues.hasIssueDetails).toBe(true);
expect(issues.issues).toEqual([]);
});
it("compacts affected items and caps them at ten entries", () => {
const items = Array.from({ length: 12 }, (_, index) => ({
url: `https://cdn.example.com/script-${index}.js`,
wastedBytes: 1000 + index,
extraField: "ignored",
}));
const issues = buildStoredLighthouseIssues({
audits: {
"unused-javascript": {
title: "Reduce unused JavaScript",
description: "Trim dead code.",
score: 0,
scoreDisplayMode: "metricSavings",
details: {
overallSavingsBytes: 50000,
items,
},
},
},
categories: {
performance: { auditRefs: [{ id: "unused-javascript" }] },
accessibility: { auditRefs: [] },
"best-practices": { auditRefs: [] },
seo: { auditRefs: [] },
},
});
expect(issues.issues).toHaveLength(1);
expect(issues.issues[0]?.items).toHaveLength(10);
expect(issues.issues[0]?.items[0]).toBe(
'{"url":"https://cdn.example.com/script-0.js","wastedBytes":1000}',
);
});
});

View File

@ -0,0 +1,310 @@
import { z } from "zod";
import {
LIGHTHOUSE_CATEGORIES,
type LighthouseCategory,
} from "@/shared/lighthouse";
export type StoredLighthouseIssue = {
category: LighthouseCategory;
auditKey: string;
title: string;
description: string;
score: number | null;
scoreDisplayMode: string | null;
displayValue: string | null;
impactMs: number | null;
impactBytes: number | null;
severity: "critical" | "warning" | "info";
items: string[];
};
type StoredLighthouseMetric = {
score: number | null;
displayValue: string | null;
numericValue: number | null;
};
export type StoredLighthouseMetrics = {
firstContentfulPaint: StoredLighthouseMetric;
largestContentfulPaint: StoredLighthouseMetric;
totalBlockingTime: StoredLighthouseMetric;
cumulativeLayoutShift: StoredLighthouseMetric;
speedIndex: StoredLighthouseMetric;
timeToInteractive: StoredLighthouseMetric;
interactionToNextPaint: StoredLighthouseMetric;
serverResponseTime: StoredLighthouseMetric;
};
export type StoredLighthousePayload = {
version: 2;
source: "dataforseo-lighthouse";
hasIssueDetails: boolean;
metadata: {
requestedUrl: string;
finalUrl: string;
strategy: "mobile" | "desktop";
fetchedAt: string;
lighthouseVersion: string | null;
taskId: string | null;
cost: number | null;
};
scores: {
performance: number | null;
accessibility: number | null;
"best-practices": number | null;
seo: number | null;
};
metrics: StoredLighthouseMetrics;
issues: StoredLighthouseIssue[];
};
export type RawLighthouseAudit = {
title?: string;
description?: string;
score?: number | null;
scoreDisplayMode?: string;
displayValue?: string;
numericValue?: number;
details?: {
overallSavingsMs?: number;
overallSavingsBytes?: number;
items?: Array<Record<string, unknown>>;
};
};
export type RawLighthouseCategory = {
score?: number | null;
auditRefs?: Array<{
id?: string;
}>;
};
const storedLighthouseMetricSchema = z.object({
score: z.number().nullable(),
displayValue: z.string().nullable(),
numericValue: z.number().nullable(),
});
export const storedLighthousePayloadSchema = z.object({
version: z.literal(2),
source: z.literal("dataforseo-lighthouse"),
hasIssueDetails: z.boolean(),
metadata: z.object({
requestedUrl: z.string(),
finalUrl: z.string(),
strategy: z.enum(["mobile", "desktop"]),
fetchedAt: z.string(),
lighthouseVersion: z.string().nullable(),
taskId: z.string().nullable(),
cost: z.number().nullable(),
}),
scores: z.object({
performance: z.number().nullable(),
accessibility: z.number().nullable(),
"best-practices": z.number().nullable(),
seo: z.number().nullable(),
}),
metrics: z.object({
firstContentfulPaint: storedLighthouseMetricSchema,
largestContentfulPaint: storedLighthouseMetricSchema,
totalBlockingTime: storedLighthouseMetricSchema,
cumulativeLayoutShift: storedLighthouseMetricSchema,
speedIndex: storedLighthouseMetricSchema,
timeToInteractive: storedLighthouseMetricSchema,
interactionToNextPaint: storedLighthouseMetricSchema,
serverResponseTime: storedLighthouseMetricSchema,
}),
issues: z.array(
z.object({
category: z.enum(LIGHTHOUSE_CATEGORIES),
auditKey: z.string(),
title: z.string(),
description: z.string(),
score: z.number().nullable(),
scoreDisplayMode: z.string().nullable(),
displayValue: z.string().nullable(),
impactMs: z.number().nullable(),
impactBytes: z.number().nullable(),
severity: z.enum(["critical", "warning", "info"]),
items: z.array(z.string()),
}),
),
});
export function scoreToPercent(
score: number | null | undefined,
): number | null {
if (score == null || Number.isNaN(score)) return null;
return Math.round(score * 100);
}
function buildStoredMetric(
audit: RawLighthouseAudit | undefined,
): StoredLighthouseMetric {
return {
score: scoreToPercent(audit?.score),
displayValue: audit?.displayValue ?? null,
numericValue:
typeof audit?.numericValue === "number" ? audit.numericValue : null,
};
}
const DIAGNOSTIC_AUDIT_KEYS = new Set([
"largest-contentful-paint-element",
"layout-shifts",
"diagnostics",
"metrics",
"network-requests",
"network-rtt",
"network-server-latency",
"main-thread-tasks",
"screenshot-thumbnails",
"final-screenshot",
"script-treemap-data",
"resource-summary",
]);
function compactItem(item: Record<string, unknown>): string {
const preferredKeys = [
"url",
"source",
"nodeLabel",
"snippet",
"totalBytes",
"wastedBytes",
"wastedMs",
"label",
"value",
];
const output: Record<string, unknown> = {};
for (const key of preferredKeys) {
if (item[key] != null) {
output[key] = item[key];
}
}
if (Object.keys(output).length === 0) {
for (const [key, value] of Object.entries(item).slice(0, 6)) {
output[key] = value;
}
}
return JSON.stringify(output);
}
function getSeverity(input: {
score: number | null;
impactMs: number | null;
impactBytes: number | null;
}): "critical" | "warning" | "info" {
if ((input.impactMs ?? 0) >= 300 || (input.impactBytes ?? 0) >= 150_000) {
return "critical";
}
if (input.score != null && input.score < 50) {
return "critical";
}
if ((input.impactMs ?? 0) >= 100 || (input.impactBytes ?? 0) >= 50_000) {
return "warning";
}
if (input.score != null && input.score < 90) {
return "warning";
}
return "info";
}
export function buildStoredLighthouseIssues(input: {
audits: Record<string, RawLighthouseAudit>;
categories: Record<string, RawLighthouseCategory>;
}) {
const hasIssueDetails = LIGHTHOUSE_CATEGORIES.some(
(category) => (input.categories[category]?.auditRefs?.length ?? 0) > 0,
);
const issues: StoredLighthouseIssue[] = [];
for (const category of LIGHTHOUSE_CATEGORIES) {
const refs = input.categories[category]?.auditRefs ?? [];
for (const ref of refs) {
const auditKey = ref.id;
if (!auditKey) continue;
const audit = input.audits[auditKey];
if (!audit) continue;
const score = scoreToPercent(audit.score);
const scoreDisplayMode = audit.scoreDisplayMode ?? null;
if (scoreDisplayMode === "numeric") continue;
if (DIAGNOSTIC_AUDIT_KEYS.has(auditKey)) continue;
const isPass =
score == null ||
(score != null && score >= 90) ||
scoreDisplayMode === "notApplicable" ||
scoreDisplayMode === "informative" ||
scoreDisplayMode === "manual" ||
scoreDisplayMode === "error";
if (isPass) continue;
const impactMs =
typeof audit.details?.overallSavingsMs === "number"
? audit.details.overallSavingsMs
: null;
const impactBytes =
typeof audit.details?.overallSavingsBytes === "number"
? audit.details.overallSavingsBytes
: null;
const items = Array.isArray(audit.details?.items)
? audit.details.items.slice(0, 10).map(compactItem)
: [];
issues.push({
category,
auditKey,
title: audit.title ?? auditKey,
description: audit.description ?? "",
score,
scoreDisplayMode,
displayValue: audit.displayValue ?? null,
impactMs,
impactBytes,
severity: getSeverity({ score, impactMs, impactBytes }),
items,
});
}
}
return {
hasIssueDetails,
issues,
};
}
export function buildStoredLighthouseMetrics(input: {
audits: Record<string, RawLighthouseAudit>;
}): StoredLighthouseMetrics {
return {
firstContentfulPaint: buildStoredMetric(
input.audits["first-contentful-paint"],
),
largestContentfulPaint: buildStoredMetric(
input.audits["largest-contentful-paint"],
),
totalBlockingTime: buildStoredMetric(input.audits["total-blocking-time"]),
cumulativeLayoutShift: buildStoredMetric(
input.audits["cumulative-layout-shift"],
),
speedIndex: buildStoredMetric(input.audits["speed-index"]),
timeToInteractive: buildStoredMetric(input.audits.interactive),
interactionToNextPaint: buildStoredMetric(
input.audits["interaction-to-next-paint"],
),
serverResponseTime: buildStoredMetric(input.audits["server-response-time"]),
};
}

View File

@ -9,12 +9,14 @@ import {
type WorkflowEvent, type WorkflowEvent,
type WorkflowStep, type WorkflowStep,
} from "cloudflare:workers"; } from "cloudflare:workers";
import type { BillingCustomerContext } from "@/server/billing/subscription";
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository"; import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
import type { AuditConfig } from "@/server/lib/audit/types"; import type { AuditConfig } from "@/server/lib/audit/types";
import { runAuditPhases } from "@/server/workflows/siteAuditWorkflowPhases"; import { runAuditPhases } from "@/server/workflows/siteAuditWorkflowPhases";
interface AuditParams { interface AuditParams {
auditId: string; auditId: string;
billingCustomer: BillingCustomerContext;
projectId: string; projectId: string;
startUrl: string; startUrl: string;
config: AuditConfig; config: AuditConfig;
@ -22,7 +24,8 @@ interface AuditParams {
export class SiteAuditWorkflow extends WorkflowEntrypoint<Env, AuditParams> { export class SiteAuditWorkflow extends WorkflowEntrypoint<Env, AuditParams> {
async run(event: WorkflowEvent<AuditParams>, step: WorkflowStep) { async run(event: WorkflowEvent<AuditParams>, step: WorkflowStep) {
const { auditId, projectId, startUrl, config } = event.payload; const { auditId, billingCustomer, projectId, startUrl, config } =
event.payload;
const audit = await AuditRepository.getAuditForWorkflow( const audit = await AuditRepository.getAuditForWorkflow(
auditId, auditId,
@ -41,6 +44,7 @@ export class SiteAuditWorkflow extends WorkflowEntrypoint<Env, AuditParams> {
await runAuditPhases(step, { await runAuditPhases(step, {
auditId, auditId,
workflowInstanceId: event.instanceId, workflowInstanceId: event.instanceId,
billingCustomer,
projectId, projectId,
startUrl, startUrl,
config, config,

View File

@ -1,64 +1,6 @@
import { analyzeHtml } from "@/server/lib/audit/page-analyzer"; import { analyzeHtml } from "@/server/lib/audit/page-analyzer";
import { fetchPsiResult } from "@/server/lib/audit/psi"; import type { StepPageResult } from "@/server/lib/audit/types";
import { isSameOrigin, normalizeUrl } from "@/server/lib/audit/url-utils"; import { isSameOrigin, normalizeUrl } from "@/server/lib/audit/url-utils";
import type { PsiResult } from "@/server/lib/audit/types";
import { putTextToR2 } from "@/server/lib/r2";
export interface StepPageResult {
id: string;
url: string;
statusCode: number;
redirectUrl: string | null;
title: string;
metaDescription: string;
canonicalUrl: string | null;
robotsMeta: string | null;
ogTitle: string | null;
ogDescription: string | null;
ogImage: string | null;
h1Count: number;
h2Count: number;
h3Count: number;
h4Count: number;
h5Count: number;
h6Count: number;
headingOrder: number[];
wordCount: number;
imagesTotal: number;
imagesMissingAlt: number;
images: Array<{ src: string | null; alt: string | null }>;
internalLinks: string[];
externalLinks: string[];
hasStructuredData: boolean;
hreflangTags: string[];
isIndexable: boolean;
responseTimeMs: number;
}
type PsiUploadContext = {
projectId: string;
auditId: string;
};
export async function fetchPsiAndUploadToR2(
url: string,
pageId: string,
strategy: "mobile" | "desktop",
apiKey: string,
context: PsiUploadContext,
): Promise<PsiResult> {
const result = await fetchPsiResult(url, pageId, strategy, apiKey);
if (result.rawPayloadJson) {
const key = `site-audit/${context.projectId}/${context.auditId}/${pageId}-${strategy}.json`;
const uploaded = await putTextToR2(key, result.rawPayloadJson);
result.r2Key = uploaded.key;
result.payloadSizeBytes = uploaded.sizeBytes;
result.rawPayloadJson = null;
}
return result;
}
export async function crawlPage( export async function crawlPage(
url: string, url: string,

View File

@ -1,12 +1,10 @@
import type { WorkflowStep } from "cloudflare:workers"; import type { WorkflowStep } from "cloudflare:workers";
import type { RobotsResult } from "@/server/lib/audit/discovery"; import type { RobotsResult } from "@/server/lib/audit/discovery";
import type { StepPageResult } from "@/server/lib/audit/types";
import { isSameOrigin, normalizeUrl } from "@/server/lib/audit/url-utils"; import { isSameOrigin, normalizeUrl } from "@/server/lib/audit/url-utils";
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository"; import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
import { AuditProgressKV } from "@/server/lib/audit/progress-kv"; import { AuditProgressKV } from "@/server/lib/audit/progress-kv";
import { import { crawlPage } from "@/server/workflows/site-audit-workflow-helpers";
crawlPage,
type StepPageResult,
} from "@/server/workflows/site-audit-workflow-helpers";
const CRAWL_CONCURRENCY = 25; const CRAWL_CONCURRENCY = 25;

View File

@ -1,19 +1,23 @@
import type { WorkflowStep } from "cloudflare:workers"; import type { WorkflowStep } from "cloudflare:workers";
import type { BillingCustomerContext } from "@/server/billing/subscription";
import { discoverUrls, fetchRobotsTxt } from "@/server/lib/audit/discovery"; import { discoverUrls, fetchRobotsTxt } from "@/server/lib/audit/discovery";
import { selectPsiSample } from "@/server/lib/audit/psi"; import {
fetchAndStoreLighthouseResult,
selectLighthouseSample,
} from "@/server/lib/audit/lighthouse";
import { getOrigin } from "@/server/lib/audit/url-utils"; import { getOrigin } from "@/server/lib/audit/url-utils";
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository"; import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
import { AuditProgressKV } from "@/server/lib/audit/progress-kv"; import { AuditProgressKV } from "@/server/lib/audit/progress-kv";
import type { AuditConfig, PsiResult } from "@/server/lib/audit/types"; import type {
import { AuditConfig,
fetchPsiAndUploadToR2, LighthouseResult,
type StepPageResult, StepPageResult,
} from "@/server/workflows/site-audit-workflow-helpers"; } from "@/server/lib/audit/types";
import { runCrawlPhase } from "@/server/workflows/siteAuditWorkflowCrawl"; import { runCrawlPhase } from "@/server/workflows/siteAuditWorkflowCrawl";
const PSI_URL_CONCURRENCY = 6; const LIGHTHOUSE_URL_BATCH_SIZE = 10;
function countPsiBatchResults(results: PsiResult[]): { function countLighthouseBatchResults(results: LighthouseResult[]): {
completed: number; completed: number;
failed: number; failed: number;
} { } {
@ -32,6 +36,7 @@ function countPsiBatchResults(results: PsiResult[]): {
type AuditPhasesParams = { type AuditPhasesParams = {
auditId: string; auditId: string;
workflowInstanceId: string; workflowInstanceId: string;
billingCustomer: BillingCustomerContext;
projectId: string; projectId: string;
startUrl: string; startUrl: string;
config: AuditConfig; config: AuditConfig;
@ -41,7 +46,14 @@ export async function runAuditPhases(
step: WorkflowStep, step: WorkflowStep,
params: AuditPhasesParams, params: AuditPhasesParams,
) { ) {
const { auditId, workflowInstanceId, projectId, startUrl, config } = params; const {
auditId,
workflowInstanceId,
billingCustomer,
projectId,
startUrl,
config,
} = params;
const origin = getOrigin(startUrl); const origin = getOrigin(startUrl);
const maxPages = config.maxPages; const maxPages = config.maxPages;
@ -62,15 +74,22 @@ export async function runAuditPhases(
robots, robots,
sitemapUrls: discovery.sitemapUrls, sitemapUrls: discovery.sitemapUrls,
}); });
const psiResults = await runPsiPhase(step, { const lighthouseResults = await runLighthousePhase(step, {
auditId, auditId,
workflowInstanceId, workflowInstanceId,
billingCustomer,
projectId, projectId,
startUrl, startUrl,
config, config,
allPages, allPages,
}); });
await finalizeAudit(step, auditId, workflowInstanceId, allPages, psiResults); await finalizeAudit(
step,
auditId,
workflowInstanceId,
allPages,
lighthouseResults,
);
} }
async function runDiscoveryPhase( async function runDiscoveryPhase(
@ -90,115 +109,134 @@ async function runDiscoveryPhase(
}); });
} }
type PsiPhaseParams = { type LighthousePhaseParams = {
auditId: string; auditId: string;
workflowInstanceId: string; workflowInstanceId: string;
billingCustomer: BillingCustomerContext;
projectId: string; projectId: string;
startUrl: string; startUrl: string;
config: AuditConfig; config: AuditConfig;
allPages: StepPageResult[]; allPages: StepPageResult[];
}; };
async function runPsiPhase( async function runLighthousePhase(
step: WorkflowStep, step: WorkflowStep,
params: PsiPhaseParams, params: LighthousePhaseParams,
): Promise<PsiResult[]> { ): Promise<LighthouseResult[]> {
const { auditId, workflowInstanceId, projectId, startUrl, config, allPages } = const {
params; auditId,
if (config.psiStrategy === "none" || !config.psiApiKey) return []; workflowInstanceId,
billingCustomer,
projectId,
startUrl,
config,
allPages,
} = params;
if (config.lighthouseStrategy === "none") return [];
const psiSample = await selectPsiUrls({ const lighthouseWork = await selectLighthousePages({
step, step,
auditId, auditId,
workflowInstanceId, workflowInstanceId,
allPages, allPages,
startUrl, startUrl,
strategy: config.psiStrategy, strategy: config.lighthouseStrategy,
});
const psiWork = psiSample.flatMap((psiUrl) => {
const page = allPages.find((candidate) => candidate.url === psiUrl);
if (!page) return [];
return [{ url: psiUrl, pageId: page.id }];
}); });
const psiResults: PsiResult[] = []; const lighthouseResults: LighthouseResult[] = [];
let psiCompleted = 0; let completedChecks = 0;
let psiFailed = 0; let failedChecks = 0;
let psiBatchIndex = 0; let lighthouseBatchIndex = 0;
for (let i = 0; i < psiWork.length; i += PSI_URL_CONCURRENCY) { for (let i = 0; i < lighthouseWork.length; i += LIGHTHOUSE_URL_BATCH_SIZE) {
const batch = psiWork.slice(i, i + PSI_URL_CONCURRENCY); const batch = lighthouseWork.slice(i, i + LIGHTHOUSE_URL_BATCH_SIZE);
psiBatchIndex += 1; lighthouseBatchIndex += 1;
const psiBatchResults = await runPsiBatch({ const lighthouseBatchResults = await runLighthouseBatch({
step, step,
psiBatchIndex, lighthouseBatchIndex,
batch, batch,
psiApiKey: config.psiApiKey, billingCustomer,
projectId, projectId,
auditId, auditId,
}); });
psiResults.push(...psiBatchResults); lighthouseResults.push(...lighthouseBatchResults);
const counts = countPsiBatchResults(psiBatchResults); const counts = countLighthouseBatchResults(lighthouseBatchResults);
psiFailed += counts.failed; failedChecks += counts.failed;
psiCompleted += counts.completed; completedChecks += counts.completed;
await step.do(`psi-progress-batch-${psiBatchIndex}`, async () => { await step.do(
`lighthouse-progress-batch-${lighthouseBatchIndex}`,
async () => {
await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, { await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, {
psiCompleted, lighthouseCompleted: completedChecks,
psiFailed, lighthouseFailed: failedChecks,
});
}); });
},
);
} }
return psiResults; return lighthouseResults;
} }
async function selectPsiUrls(params: { async function selectLighthousePages(params: {
step: WorkflowStep; step: WorkflowStep;
auditId: string; auditId: string;
workflowInstanceId: string; workflowInstanceId: string;
allPages: StepPageResult[]; allPages: StepPageResult[];
startUrl: string; startUrl: string;
strategy: AuditConfig["psiStrategy"]; strategy: AuditConfig["lighthouseStrategy"];
}) { }) {
const { step, auditId, workflowInstanceId, allPages, startUrl, strategy } = const { step, auditId, workflowInstanceId, allPages, startUrl, strategy } =
params; params;
return step.do("select-psi-sample", async () => { return step.do("select-lighthouse-sample", async () => {
const pagesForSample = allPages.map((page) => ({ const sample = selectLighthouseSample(allPages, startUrl, strategy);
id: page.id, const selectedUrls = new Set(sample);
url: page.url,
statusCode: page.statusCode,
}));
const sample = selectPsiSample(pagesForSample, startUrl, strategy);
await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, { await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, {
currentPhase: "psi", currentPhase: "lighthouse",
psiTotal: sample.length * 2, lighthouseTotal: sample.length * 2,
psiCompleted: 0, lighthouseCompleted: 0,
psiFailed: 0, lighthouseFailed: 0,
}); });
return sample; return allPages.flatMap((page) =>
selectedUrls.has(page.url) ? [{ url: page.url, pageId: page.id }] : [],
);
}); });
} }
async function runPsiBatch(params: { async function runLighthouseBatch(params: {
step: WorkflowStep; step: WorkflowStep;
psiBatchIndex: number; lighthouseBatchIndex: number;
batch: Array<{ url: string; pageId: string }>; batch: Array<{ url: string; pageId: string }>;
psiApiKey: string; billingCustomer: BillingCustomerContext;
projectId: string; projectId: string;
auditId: string; auditId: string;
}) { }) {
const { step, psiBatchIndex, batch, psiApiKey, projectId, auditId } = params; const {
return step.do(`psi-batch-${psiBatchIndex}`, async () => { step,
lighthouseBatchIndex,
batch,
billingCustomer,
projectId,
auditId,
} = params;
return step.do(`lighthouse-batch-${lighthouseBatchIndex}`, async () => {
const perUrlResults = await Promise.all( const perUrlResults = await Promise.all(
batch.map(async ({ url, pageId }) => { batch.map(async ({ url, pageId }) => {
const [mobileResult, desktopResult] = await Promise.all([ const [mobileResult, desktopResult] = await Promise.all([
fetchPsiAndUploadToR2(url, pageId, "mobile", psiApiKey, { fetchAndStoreLighthouseResult({
url,
pageId,
strategy: "mobile",
billingCustomer,
projectId, projectId,
auditId, auditId,
}), }),
fetchPsiAndUploadToR2(url, pageId, "desktop", psiApiKey, { fetchAndStoreLighthouseResult({
url,
pageId,
strategy: "desktop",
billingCustomer,
projectId, projectId,
auditId, auditId,
}), }),
@ -216,13 +254,17 @@ async function finalizeAudit(
auditId: string, auditId: string,
workflowInstanceId: string, workflowInstanceId: string,
allPages: StepPageResult[], allPages: StepPageResult[],
psiResults: PsiResult[], lighthouseResults: LighthouseResult[],
) { ) {
await step.do("finalize", async () => { await step.do("finalize", async () => {
await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, { await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, {
currentPhase: "finalizing", currentPhase: "finalizing",
}); });
await AuditRepository.batchWriteResults(auditId, allPages, psiResults); await AuditRepository.batchWriteResults(
auditId,
allPages,
lighthouseResults,
);
await AuditRepository.completeAudit(auditId, workflowInstanceId, { await AuditRepository.completeAudit(auditId, workflowInstanceId, {
pagesCrawled: allPages.length, pagesCrawled: allPages.length,
pagesTotal: allPages.length, pagesTotal: allPages.length,

View File

@ -1,14 +1,14 @@
import { createServerFn } from "@tanstack/react-start"; import { createServerFn } from "@tanstack/react-start";
import { AuditService } from "@/server/features/audit/services/AuditService";
import { requireProjectContext } from "@/serverFunctions/middleware"; import { requireProjectContext } from "@/serverFunctions/middleware";
import { import {
startAuditSchema,
getAuditStatusSchema,
getAuditResultsSchema,
getAuditHistorySchema,
deleteAuditSchema, deleteAuditSchema,
getAuditHistorySchema,
getAuditResultsSchema,
getAuditStatusSchema,
getCrawlProgressSchema, getCrawlProgressSchema,
startAuditSchema,
} from "@/types/schemas/audit"; } from "@/types/schemas/audit";
import { AuditService } from "@/server/features/audit/services/AuditService";
export const startAudit = createServerFn({ method: "POST" }) export const startAudit = createServerFn({ method: "POST" })
.middleware(requireProjectContext) .middleware(requireProjectContext)
@ -16,11 +16,14 @@ export const startAudit = createServerFn({ method: "POST" })
.handler(async ({ data, context }) => { .handler(async ({ data, context }) => {
return AuditService.startAudit({ return AuditService.startAudit({
actorUserId: context.userId, actorUserId: context.userId,
billingCustomer: {
organizationId: context.organizationId,
userEmail: context.userEmail,
},
projectId: context.project.id, projectId: context.project.id,
startUrl: data.startUrl, startUrl: data.startUrl,
maxPages: data.maxPages, maxPages: data.maxPages,
psiStrategy: data.psiStrategy, lighthouseStrategy: data.lighthouseStrategy,
psiApiKey: data.psiApiKey,
}); });
}); });
@ -38,9 +41,7 @@ export const getAuditResults = createServerFn({ method: "POST" })
return AuditService.getResults(data.auditId, context.project.id); return AuditService.getResults(data.auditId, context.project.id);
}); });
export const getAuditHistory = createServerFn({ export const getAuditHistory = createServerFn({ method: "POST" })
method: "POST",
})
.middleware(requireProjectContext) .middleware(requireProjectContext)
.inputValidator((data: unknown) => getAuditHistorySchema.parse(data)) .inputValidator((data: unknown) => getAuditHistorySchema.parse(data))
.handler(async ({ context }) => { .handler(async ({ context }) => {

View File

@ -0,0 +1,88 @@
import { createServerFn } from "@tanstack/react-start";
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
import {
buildLighthouseExportFile,
readStoredLighthousePayload,
} from "@/server/lib/lighthousePayload";
import { AppError } from "@/server/lib/errors";
import { getJsonFromR2 } from "@/server/lib/r2";
import { requireProjectContext } from "@/serverFunctions/middleware";
import {
lighthouseAuditExportSchema,
lighthouseAuditIssueSchema,
} from "@/types/schemas/lighthouse";
async function getAuditLighthouseData(input: {
projectId: string;
resultId: string;
}) {
const site = await AuditRepository.getLighthouseResultById({
lighthouseResultId: input.resultId,
projectId: input.projectId,
});
if (!site) {
throw new AppError("NOT_FOUND");
}
const r2Key = site.lighthouse.r2Key;
if (!r2Key) {
throw new AppError("NOT_FOUND");
}
const payloadJson = await getJsonFromR2(r2Key);
const payload = readStoredLighthousePayload(payloadJson);
return {
id: site.lighthouse.id,
strategy: site.lighthouse.strategy,
finalUrl: site.page?.url ?? "",
createdAt: site.audit.startedAt,
payloadJson,
payload,
};
}
export const getAuditLighthouseIssues = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => lighthouseAuditIssueSchema.parse(data))
.handler(async ({ data, context }) => {
const lighthouse = await getAuditLighthouseData({
projectId: context.project.id,
resultId: data.resultId,
});
return {
id: lighthouse.id,
finalUrl:
lighthouse.payload.storedPayload?.metadata.finalUrl ??
lighthouse.finalUrl,
strategy: lighthouse.strategy,
createdAt: lighthouse.createdAt,
hasIssueDetails: lighthouse.payload.report.hasIssueDetails,
scores: lighthouse.payload.storedPayload?.scores ?? null,
metrics: lighthouse.payload.storedPayload?.metrics ?? null,
issues: lighthouse.payload.report.issues,
};
});
export const exportAuditLighthouseIssues = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => lighthouseAuditExportSchema.parse(data))
.handler(async ({ data, context }) => {
const lighthouse = await getAuditLighthouseData({
projectId: context.project.id,
resultId: data.resultId,
});
return buildLighthouseExportFile({
idField: "resultId",
idValue: lighthouse.id,
finalUrl: lighthouse.finalUrl,
strategy: lighthouse.strategy,
createdAt: lighthouse.createdAt,
payloadJson: lighthouse.payloadJson,
mode: data.mode,
category: data.mode === "category" ? data.category : undefined,
});
});

View File

@ -1,9 +1,6 @@
import { createServerFn } from "@tanstack/react-start"; import { createServerFn } from "@tanstack/react-start";
import { ProjectService } from "@/server/features/projects/services/ProjectService"; import { ProjectService } from "@/server/features/projects/services/ProjectService";
import { import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
requireAuthenticatedContext,
requireProjectContext,
} from "@/serverFunctions/middleware";
import { z } from "zod"; import { z } from "zod";
export const getOrCreateDefaultProject = createServerFn({ method: "POST" }) export const getOrCreateDefaultProject = createServerFn({ method: "POST" })
@ -13,13 +10,13 @@ export const getOrCreateDefaultProject = createServerFn({ method: "POST" })
); );
export const getProjectAccess = createServerFn({ method: "POST" }) export const getProjectAccess = createServerFn({ method: "POST" })
.middleware(requireProjectContext) .middleware(requireAuthenticatedContext)
.inputValidator((data: unknown) => .inputValidator((data: unknown) =>
z.object({ projectId: z.string().min(1) }).parse(data), z.object({ projectId: z.string().min(1) }).parse(data),
) )
.handler(async ({ context }) => { .handler(async ({ data, context }) => {
return ProjectService.getProjectForOrganization( return ProjectService.getProjectForOrganization(
context.organizationId, context.organizationId,
context.project.id, data.projectId,
); );
}); });

View File

@ -1,66 +0,0 @@
import { createServerFn } from "@tanstack/react-start";
import { PsiAuditService } from "@/server/features/psi/services/PsiAuditService";
import { requireProjectContext } from "@/serverFunctions/middleware";
import {
psiAuditIssueSchema,
psiAuditExportSchema,
psiProjectKeySchema,
psiProjectSchema,
} from "@/types/schemas/psi";
export const getProjectPsiApiKey = createServerFn({
method: "POST",
})
.middleware(requireProjectContext)
.inputValidator((data: unknown) => psiProjectSchema.parse(data))
.handler(async ({ context }) => {
return PsiAuditService.getProjectPsiApiKey({
projectId: context.project.id,
});
});
export const saveProjectPsiApiKey = createServerFn({
method: "POST",
})
.middleware(requireProjectContext)
.inputValidator((data: unknown) => psiProjectKeySchema.parse(data))
.handler(async ({ data, context }) => {
return PsiAuditService.saveProjectPsiApiKey({
projectId: context.project.id,
apiKey: data.apiKey,
});
});
export const clearProjectPsiApiKey = createServerFn({
method: "POST",
})
.middleware(requireProjectContext)
.inputValidator((data: unknown) => psiProjectSchema.parse(data))
.handler(async ({ context }) => {
return PsiAuditService.clearProjectPsiApiKey({
projectId: context.project.id,
});
});
export const getAuditPsiIssues = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => psiAuditIssueSchema.parse(data))
.handler(async ({ data, context }) => {
return PsiAuditService.getAuditPsiIssues({
projectId: context.project.id,
resultId: data.resultId,
category: data.category,
});
});
export const exportAuditPsi = createServerFn({ method: "POST" })
.middleware(requireProjectContext)
.inputValidator((data: unknown) => psiAuditExportSchema.parse(data))
.handler(async ({ data, context }) => {
return PsiAuditService.exportAuditPsi({
projectId: context.project.id,
resultId: data.resultId,
mode: data.mode,
category: data.category,
});
});

14
src/shared/lighthouse.ts Normal file
View File

@ -0,0 +1,14 @@
export const LIGHTHOUSE_CATEGORIES = [
"performance",
"accessibility",
"best-practices",
"seo",
] as const;
export const LIGHTHOUSE_CATEGORY_TABS = [
"all",
...LIGHTHOUSE_CATEGORIES,
] as const;
export type LighthouseCategory = (typeof LIGHTHOUSE_CATEGORIES)[number];
export type LighthouseCategoryTab = (typeof LIGHTHOUSE_CATEGORY_TABS)[number];

View File

@ -6,11 +6,10 @@ export const startAuditSchema = z.object({
projectId: z.string().min(1), projectId: z.string().min(1),
startUrl: z.string().min(1, "URL is required").max(2048), startUrl: z.string().min(1, "URL is required").max(2048),
maxPages: z.number().int().min(10).max(10_000).optional().default(50), maxPages: z.number().int().min(10).max(10_000).optional().default(50),
psiStrategy: z lighthouseStrategy: z
.enum(["auto", "all", "manual", "none"]) .enum(["auto", "all", "manual", "none"])
.optional() .optional()
.default("auto"), .default("auto"),
psiApiKey: z.string().optional(),
}); });
export const getAuditStatusSchema = z.object({ export const getAuditStatusSchema = z.object({

View File

@ -0,0 +1,22 @@
import { z } from "zod";
import {
LIGHTHOUSE_CATEGORIES,
LIGHTHOUSE_CATEGORY_TABS,
} from "@/shared/lighthouse";
export const lighthouseAuditIssueSchema = z.object({
projectId: z.string().min(1, "Project id is required"),
resultId: z.string().min(1, "Result id is required"),
});
export const lighthouseAuditExportSchema = z.object({
projectId: z.string().min(1, "Project id is required"),
resultId: z.string().min(1, "Result id is required"),
mode: z.enum(["full", "issues", "category"]),
category: z.enum(LIGHTHOUSE_CATEGORIES).optional(),
});
export const lighthouseIssuesSearchSchema = z.object({
auditId: z.string().optional().catch(undefined),
category: z.enum(LIGHTHOUSE_CATEGORY_TABS).catch("all").default("all"),
});

View File

@ -1,37 +0,0 @@
import { z } from "zod";
const psiCategories = [
"performance",
"accessibility",
"best-practices",
"seo",
] as const;
export const psiProjectKeySchema = z.object({
projectId: z.string().min(1, "Project is required"),
apiKey: z.string().min(1, "API key is required").max(512),
});
export const psiProjectSchema = z.object({
projectId: z.string().min(1, "Project is required"),
});
export const psiAuditIssueSchema = z.object({
projectId: z.string().min(1, "Project is required"),
resultId: z.string().min(1, "Result id is required"),
category: z.enum(psiCategories).optional(),
});
export const psiAuditExportSchema = z.object({
projectId: z.string().min(1, "Project is required"),
resultId: z.string().min(1, "Result id is required"),
mode: z.enum(["full", "issues", "category"]),
category: z.enum(psiCategories).optional(),
});
export const psiIssuesSearchSchema = z.object({
category: z
.enum(["all", ...psiCategories])
.catch("all")
.default("all"),
});