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:
parent
739b3f0b6a
commit
638f5a6602
3
.gitignore
vendored
3
.gitignore
vendored
@ -32,3 +32,6 @@ dist/
|
||||
|
||||
# Localflare generated files
|
||||
.localflare/
|
||||
|
||||
# Local Claude config
|
||||
.claude/
|
||||
|
||||
@ -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 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
|
||||
|
||||
|
||||
30
drizzle/0005_low_red_hulk.sql
Normal file
30
drizzle/0005_low_red_hulk.sql
Normal 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`);
|
||||
1
drizzle/0006_magical_alex_wilder.sql
Normal file
1
drizzle/0006_magical_alex_wilder.sql
Normal file
@ -0,0 +1 @@
|
||||
ALTER TABLE `projects` DROP COLUMN `pagespeed_api_key`;
|
||||
1526
drizzle/meta/0005_snapshot.json
Normal file
1526
drizzle/meta/0005_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1513
drizzle/meta/0006_snapshot.json
Normal file
1513
drizzle/meta/0006_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -36,6 +36,20 @@
|
||||
"when": 1773935379368,
|
||||
"tag": "0004_faithful_sunset_bain",
|
||||
"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
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -38,7 +38,7 @@ export function AuditHistorySection({
|
||||
<th>URL</th>
|
||||
<th>Status</th>
|
||||
<th>Pages</th>
|
||||
<th>PSI</th>
|
||||
<th>Lighthouse</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -54,7 +54,7 @@ export function AuditHistorySection({
|
||||
</td>
|
||||
<td>{audit.pagesTotal || audit.pagesCrawled}</td>
|
||||
<td>
|
||||
{audit.ranPsi ? (
|
||||
{audit.ranLighthouse ? (
|
||||
<span className="badge badge-ghost badge-xs">Yes</span>
|
||||
) : null}
|
||||
</td>
|
||||
|
||||
@ -1,48 +1,33 @@
|
||||
import type { FormEvent } from "react";
|
||||
import { Loader2, Settings } from "lucide-react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import {
|
||||
MAX_PAGES_LIMIT,
|
||||
MIN_PAGES,
|
||||
type LaunchFormApi,
|
||||
type LaunchState,
|
||||
type SettingsFormApi,
|
||||
} from "@/client/features/audit/launch/types";
|
||||
|
||||
export function LaunchFormCard({
|
||||
launchForm,
|
||||
settingsForm,
|
||||
state,
|
||||
setState,
|
||||
isPending,
|
||||
onSubmit,
|
||||
onOpenSettings,
|
||||
onRunPsiToggle,
|
||||
onRunLighthouseToggle,
|
||||
commitMaxPagesInput,
|
||||
}: {
|
||||
launchForm: LaunchFormApi;
|
||||
settingsForm: SettingsFormApi;
|
||||
state: LaunchState;
|
||||
setState: React.Dispatch<React.SetStateAction<LaunchState>>;
|
||||
isPending: boolean;
|
||||
onSubmit: (event: FormEvent) => void;
|
||||
onOpenSettings: () => void;
|
||||
onRunPsiToggle: (checked: boolean) => void;
|
||||
onRunLighthouseToggle: (checked: boolean) => void;
|
||||
commitMaxPagesInput: () => number;
|
||||
}) {
|
||||
return (
|
||||
<div className="card bg-base-100 border border-base-300">
|
||||
<div className="card-body gap-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<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
|
||||
className="grid grid-cols-1 gap-3 lg:grid-cols-12 lg:items-center"
|
||||
@ -85,10 +70,9 @@ export function LaunchFormCard({
|
||||
launchForm={launchForm}
|
||||
commitMaxPagesInput={commitMaxPagesInput}
|
||||
/>
|
||||
<PsiOptions
|
||||
<LighthouseOptions
|
||||
launchForm={launchForm}
|
||||
settingsForm={settingsForm}
|
||||
onRunPsiToggle={onRunPsiToggle}
|
||||
onRunLighthouseToggle={onRunLighthouseToggle}
|
||||
/>
|
||||
</div>
|
||||
</form>
|
||||
@ -138,42 +122,42 @@ function LaunchOptions({
|
||||
);
|
||||
}
|
||||
|
||||
function PsiOptions({
|
||||
function LighthouseOptions({
|
||||
launchForm,
|
||||
settingsForm,
|
||||
onRunPsiToggle,
|
||||
onRunLighthouseToggle,
|
||||
}: {
|
||||
launchForm: LaunchFormApi;
|
||||
settingsForm: SettingsFormApi;
|
||||
onRunPsiToggle: (checked: boolean) => void;
|
||||
onRunLighthouseToggle: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<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">
|
||||
<launchForm.Field name="runPsi">
|
||||
<launchForm.Field name="runLighthouse">
|
||||
{(field) => (
|
||||
<input
|
||||
type="checkbox"
|
||||
className="toggle toggle-sm toggle-primary"
|
||||
checked={Boolean(field.state.value)}
|
||||
onChange={(event) => onRunPsiToggle(event.target.checked)}
|
||||
onChange={(event) => onRunLighthouseToggle(event.target.checked)}
|
||||
/>
|
||||
)}
|
||||
</launchForm.Field>
|
||||
<span
|
||||
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>
|
||||
</label>
|
||||
|
||||
<launchForm.Subscribe selector={(snapshot) => snapshot.values.runPsi}>
|
||||
{(runPsi) =>
|
||||
runPsi ? (
|
||||
<launchForm.Subscribe
|
||||
selector={(snapshot) => snapshot.values.runLighthouse}
|
||||
>
|
||||
{(runLighthouse) =>
|
||||
runLighthouse ? (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-xs text-base-content/60">PSI mode</span>
|
||||
<launchForm.Field name="psiMode">
|
||||
<span className="text-xs text-base-content/60">Audit scope</span>
|
||||
<launchForm.Field name="lighthouseMode">
|
||||
{(field) => (
|
||||
<select
|
||||
className="select select-bordered select-xs"
|
||||
@ -189,17 +173,9 @@ function PsiOptions({
|
||||
</select>
|
||||
)}
|
||||
</launchForm.Field>
|
||||
<settingsForm.Subscribe
|
||||
selector={(snapshot) => snapshot.values.psiApiKey}
|
||||
>
|
||||
{(psiApiKey) => (
|
||||
<span
|
||||
className={`text-xs ${psiApiKey.trim() ? "text-success/80" : "text-warning"}`}
|
||||
>
|
||||
{psiApiKey.trim() ? "PSI key saved" : "PSI key required"}
|
||||
<span className="text-xs text-base-content/50">
|
||||
Powered by DataForSEO Lighthouse
|
||||
</span>
|
||||
)}
|
||||
</settingsForm.Subscribe>
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
@ -214,11 +190,6 @@ function LaunchErrors({ state }: { state: LaunchState }) {
|
||||
{state.urlError ? (
|
||||
<p className="text-sm text-error">{state.urlError}</p>
|
||||
) : null}
|
||||
{state.psiRequirementError ? (
|
||||
<div className="alert alert-warning py-2">
|
||||
<span className="text-sm">{state.psiRequirementError}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{state.startError ? (
|
||||
<div className="alert alert-error py-2">
|
||||
<span className="text-sm">{state.startError}</span>
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { AuditHistorySection } from "@/client/features/audit/launch/AuditHistorySection";
|
||||
import { LaunchFormCard } from "@/client/features/audit/launch/LaunchFormCard";
|
||||
import { SettingsModal } from "@/client/features/audit/launch/SettingsModal";
|
||||
import { useLaunchController } from "@/client/features/audit/launch/useLaunchController";
|
||||
|
||||
export function LaunchView({
|
||||
@ -19,26 +18,14 @@ export function LaunchView({
|
||||
|
||||
<LaunchFormCard
|
||||
launchForm={controller.launchForm}
|
||||
settingsForm={controller.settingsForm}
|
||||
state={controller.state}
|
||||
setState={controller.setState}
|
||||
isPending={controller.startMutation.isPending}
|
||||
onSubmit={controller.handleSubmit}
|
||||
onOpenSettings={controller.openSettings}
|
||||
onRunPsiToggle={controller.onRunPsiToggle}
|
||||
onRunLighthouseToggle={controller.onRunLighthouseToggle}
|
||||
commitMaxPagesInput={controller.commitMaxPagesInput}
|
||||
/>
|
||||
|
||||
{controller.state.isSettingsOpen && (
|
||||
<SettingsModal
|
||||
settingsForm={controller.settingsForm}
|
||||
state={controller.state}
|
||||
setState={controller.setState}
|
||||
onClear={controller.clearPsiKey}
|
||||
onSave={controller.saveSettings}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AuditHistorySection
|
||||
history={controller.historyQuery.data ?? []}
|
||||
isLoading={controller.historyQuery.isLoading}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1,12 +1,8 @@
|
||||
import { useForm } from "@tanstack/react-form";
|
||||
|
||||
export type LaunchState = {
|
||||
isSettingsOpen: boolean;
|
||||
showPsiKey: boolean;
|
||||
urlError: string | null;
|
||||
psiRequirementError: string | null;
|
||||
startError: string | null;
|
||||
settingsError: string | null;
|
||||
};
|
||||
|
||||
export const MIN_PAGES = 10;
|
||||
@ -17,15 +13,10 @@ export function useLaunchForm() {
|
||||
defaultValues: {
|
||||
url: "",
|
||||
maxPagesInput: "50",
|
||||
runPsi: false,
|
||||
psiMode: "auto" as "auto" | "all",
|
||||
runLighthouse: false,
|
||||
lighthouseMode: "auto" as "auto" | "all",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useSettingsForm() {
|
||||
return useForm({ defaultValues: { psiApiKey: "" } });
|
||||
}
|
||||
|
||||
export type LaunchFormApi = ReturnType<typeof useLaunchForm>;
|
||||
export type SettingsFormApi = ReturnType<typeof useSettingsForm>;
|
||||
|
||||
@ -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 { toast } from "sonner";
|
||||
import {
|
||||
@ -6,16 +6,10 @@ import {
|
||||
getAuditHistory,
|
||||
startAudit,
|
||||
} from "@/serverFunctions/audit";
|
||||
import {
|
||||
clearProjectPsiApiKey,
|
||||
getProjectPsiApiKey,
|
||||
saveProjectPsiApiKey,
|
||||
} from "@/serverFunctions/psi";
|
||||
import {
|
||||
MAX_PAGES_LIMIT,
|
||||
MIN_PAGES,
|
||||
useLaunchForm,
|
||||
useSettingsForm,
|
||||
type LaunchState,
|
||||
} from "@/client/features/audit/launch/types";
|
||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||
@ -28,34 +22,20 @@ export function useLaunchController({
|
||||
onAuditStarted: (auditId: string) => void;
|
||||
}) {
|
||||
const launchForm = useLaunchForm();
|
||||
const settingsForm = useSettingsForm();
|
||||
const [state, setState] = useState<LaunchState>({
|
||||
isSettingsOpen: false,
|
||||
showPsiKey: false,
|
||||
urlError: null,
|
||||
psiRequirementError: null,
|
||||
startError: null,
|
||||
settingsError: null,
|
||||
});
|
||||
|
||||
const historyQuery = useQuery({
|
||||
queryKey: ["audit-history", projectId],
|
||||
queryFn: () => getAuditHistory({ data: { projectId } }),
|
||||
});
|
||||
const keyQuery = useQuery({
|
||||
queryKey: ["projectPsiApiKey", projectId],
|
||||
queryFn: () => getProjectPsiApiKey({ data: { projectId } }),
|
||||
});
|
||||
const { startMutation, deleteMutation, saveKeyMutation, clearKeyMutation } =
|
||||
useLaunchMutations({
|
||||
const { startMutation, deleteMutation } = useLaunchMutations({
|
||||
projectId,
|
||||
historyRefetch: historyQuery.refetch,
|
||||
keyRefetch: keyQuery.refetch,
|
||||
clearPsiApiKeyField: () => settingsForm.setFieldValue("psiApiKey", ""),
|
||||
});
|
||||
|
||||
useSyncPsiKeyField(keyQuery.data?.apiKey, settingsForm);
|
||||
|
||||
const applyMaxPages = (value: number) => {
|
||||
const safeValue = Number.isFinite(value)
|
||||
? Math.max(MIN_PAGES, Math.min(MAX_PAGES_LIMIT, Math.round(value)))
|
||||
@ -72,20 +52,13 @@ export function useLaunchController({
|
||||
|
||||
const handleStart = () => {
|
||||
const launchValues = launchForm.state.values;
|
||||
const settingsValues = settingsForm.state.values;
|
||||
const effectiveMaxPages = commitMaxPagesInput();
|
||||
setState((prev) => ({ ...prev, startError: null }));
|
||||
|
||||
if (!launchValues.url.trim())
|
||||
if (!launchValues.url.trim()) {
|
||||
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) {
|
||||
const confirmed = window.confirm(
|
||||
`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,
|
||||
startUrl: launchValues.url,
|
||||
maxPages: effectiveMaxPages,
|
||||
psiStrategy: launchValues.runPsi ? launchValues.psiMode : "none",
|
||||
psiApiKey: launchValues.runPsi
|
||||
? settingsValues.psiApiKey || undefined
|
||||
: undefined,
|
||||
lighthouseStrategy: launchValues.runLighthouse
|
||||
? launchValues.lighthouseMode
|
||||
: "none",
|
||||
},
|
||||
{
|
||||
onSuccess: (result) => {
|
||||
setState((prev) => ({
|
||||
...prev,
|
||||
urlError: null,
|
||||
psiRequirementError: null,
|
||||
startError: null,
|
||||
}));
|
||||
setState({ urlError: null, startError: null });
|
||||
toast.success("Audit started!");
|
||||
onAuditStarted(result.auditId);
|
||||
},
|
||||
@ -126,7 +93,6 @@ export function useLaunchController({
|
||||
|
||||
return {
|
||||
launchForm,
|
||||
settingsForm,
|
||||
state,
|
||||
setState,
|
||||
historyQuery,
|
||||
@ -136,45 +102,25 @@ export function useLaunchController({
|
||||
event.preventDefault();
|
||||
handleStart();
|
||||
},
|
||||
openSettings: () => setState((prev) => ({ ...prev, isSettingsOpen: true })),
|
||||
onRunPsiToggle: (checked: boolean) =>
|
||||
handleRunPsiToggle(checked, launchForm, settingsForm, setState),
|
||||
saveSettings: () =>
|
||||
handleSaveSettings(settingsForm, setState, saveKeyMutation.mutate),
|
||||
clearPsiKey: () => clearKeyMutation.mutate(),
|
||||
onRunLighthouseToggle: (checked: boolean) =>
|
||||
handleRunLighthouseToggle(checked, launchForm),
|
||||
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({
|
||||
projectId,
|
||||
historyRefetch,
|
||||
keyRefetch,
|
||||
clearPsiApiKeyField,
|
||||
}: {
|
||||
projectId: string;
|
||||
historyRefetch: () => Promise<unknown>;
|
||||
keyRefetch: () => Promise<unknown>;
|
||||
clearPsiApiKeyField: () => void;
|
||||
}) {
|
||||
const startMutation = useMutation({
|
||||
mutationFn: (data: {
|
||||
projectId: string;
|
||||
startUrl: string;
|
||||
maxPages: number;
|
||||
psiStrategy: "auto" | "all" | "none";
|
||||
psiApiKey?: string;
|
||||
lighthouseStrategy: "auto" | "all" | "none";
|
||||
}) => startAudit({ data }),
|
||||
});
|
||||
|
||||
@ -187,67 +133,12 @@ function useLaunchMutations({
|
||||
},
|
||||
});
|
||||
|
||||
const saveKeyMutation = useMutation({
|
||||
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 };
|
||||
return { startMutation, deleteMutation };
|
||||
}
|
||||
|
||||
function handleRunPsiToggle(
|
||||
function handleRunLighthouseToggle(
|
||||
checked: boolean,
|
||||
launchForm: ReturnType<typeof useLaunchForm>,
|
||||
settingsForm: ReturnType<typeof useSettingsForm>,
|
||||
setState: React.Dispatch<React.SetStateAction<LaunchState>>,
|
||||
) {
|
||||
if (!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);
|
||||
launchForm.setFieldValue("runLighthouse", checked);
|
||||
}
|
||||
|
||||
@ -2,10 +2,35 @@ import { ChevronDown, Download, ExternalLink } from "lucide-react";
|
||||
import {
|
||||
extractPathname,
|
||||
HttpStatusBadge,
|
||||
PsiScoreBadge,
|
||||
LighthouseScoreBadge,
|
||||
} from "@/client/features/audit/shared";
|
||||
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"] }) {
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
@ -22,7 +47,7 @@ export function PagesTable({ pages }: { pages: AuditResultsData["pages"] }) {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{pages.map((page) => (
|
||||
{pages.map((page: AuditResultsData["pages"][number]) => (
|
||||
<tr key={page.id}>
|
||||
<td className="max-w-[200px] truncate">
|
||||
<a
|
||||
@ -66,12 +91,14 @@ export function PagesTable({ pages }: { pages: AuditResultsData["pages"] }) {
|
||||
}
|
||||
|
||||
export function PerformanceTable({
|
||||
auditId,
|
||||
projectId,
|
||||
psi,
|
||||
lighthouse,
|
||||
pages,
|
||||
}: {
|
||||
auditId: string;
|
||||
projectId: string;
|
||||
psi: AuditResultsData["psi"];
|
||||
lighthouse: AuditResultsData["lighthouse"];
|
||||
pages: AuditResultsData["pages"];
|
||||
}) {
|
||||
return (
|
||||
@ -93,12 +120,16 @@ export function PerformanceTable({
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{psi.map((result) => (
|
||||
{lighthouse.map((result: AuditResultsData["lighthouse"][number]) => (
|
||||
<PerformanceRow
|
||||
key={result.id}
|
||||
auditId={auditId}
|
||||
projectId={projectId}
|
||||
result={result}
|
||||
page={pages.find((candidate) => candidate.id === result.pageId)}
|
||||
page={pages.find(
|
||||
(candidate: AuditResultsData["pages"][number]) =>
|
||||
candidate.id === result.pageId,
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
@ -108,15 +139,18 @@ export function PerformanceTable({
|
||||
}
|
||||
|
||||
function PerformanceRow({
|
||||
auditId,
|
||||
projectId,
|
||||
result,
|
||||
page,
|
||||
}: {
|
||||
auditId: string;
|
||||
projectId: string;
|
||||
result: AuditResultsData["psi"][number];
|
||||
result: AuditResultsData["lighthouse"][number];
|
||||
page: AuditResultsData["pages"][number] | undefined;
|
||||
}) {
|
||||
const isFailed = !!result.errorMessage;
|
||||
const isFailed = isLighthouseFailure(result);
|
||||
const failureMessage = getLighthouseFailureMessage(result);
|
||||
|
||||
return (
|
||||
<tr>
|
||||
@ -128,7 +162,7 @@ function PerformanceRow({
|
||||
{isFailed ? (
|
||||
<span
|
||||
className="badge badge-error badge-outline text-xs"
|
||||
title={result.errorMessage ?? "PSI check failed"}
|
||||
title={failureMessage}
|
||||
>
|
||||
failed
|
||||
</span>
|
||||
@ -137,13 +171,13 @@ function PerformanceRow({
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<PsiScoreBadge score={result.performanceScore} />
|
||||
<LighthouseScoreBadge score={result.performanceScore} />
|
||||
</td>
|
||||
<td>
|
||||
<PsiScoreBadge score={result.accessibilityScore} />
|
||||
<LighthouseScoreBadge score={result.accessibilityScore} />
|
||||
</td>
|
||||
<td>
|
||||
<PsiScoreBadge score={result.seoScore} />
|
||||
<LighthouseScoreBadge score={result.seoScore} />
|
||||
</td>
|
||||
<td className="text-xs">
|
||||
{result.lcpMs ? `${(result.lcpMs / 1000).toFixed(1)}s` : "-"}
|
||||
@ -158,10 +192,10 @@ function PerformanceRow({
|
||||
{result.ttfbMs ? `${Math.round(result.ttfbMs)}ms` : "-"}
|
||||
</td>
|
||||
<td>
|
||||
{result.r2Key ? (
|
||||
{result.r2Key && !isFailed ? (
|
||||
<a
|
||||
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
|
||||
</a>
|
||||
|
||||
@ -7,6 +7,7 @@ import {
|
||||
import type { AuditResultsData } from "@/client/features/audit/results/types";
|
||||
import {
|
||||
ExportDropdown,
|
||||
isLighthouseFailure,
|
||||
PagesTable,
|
||||
PerformanceTable,
|
||||
} from "@/client/features/audit/results/ResultsTables";
|
||||
@ -24,32 +25,32 @@ export function ResultsView({
|
||||
tab: string;
|
||||
setSearchParams: SearchSetter;
|
||||
}) {
|
||||
const { audit, pages, psi } = data;
|
||||
const hasPerformanceTab = psi.length > 0;
|
||||
const { audit, pages, lighthouse } = data;
|
||||
const hasPerformanceTab = lighthouse.length > 0;
|
||||
const activeTab = hasPerformanceTab ? tab : "pages";
|
||||
const stats = useResultStats(pages, psi);
|
||||
const stats = useResultStats(pages, lighthouse);
|
||||
|
||||
return (
|
||||
<>
|
||||
<StatsGrid
|
||||
pagesCrawled={audit.pagesCrawled}
|
||||
totalPages={pages.length}
|
||||
totalPsi={psi.length}
|
||||
totalLighthouse={lighthouse.length}
|
||||
averageResponseMs={stats.averageResponseMs}
|
||||
psiSummary={stats.psiSummary}
|
||||
lighthouseSummary={stats.lighthouseSummary}
|
||||
/>
|
||||
|
||||
<div className="card bg-base-100 border border-base-300">
|
||||
<div className="card-body gap-3">
|
||||
<ResultsHeader
|
||||
pageCount={pages.length}
|
||||
psiCount={psi.length}
|
||||
lighthouseCount={lighthouse.length}
|
||||
hasPerformanceTab={hasPerformanceTab}
|
||||
activeTab={activeTab}
|
||||
setSearchParams={setSearchParams}
|
||||
onExport={(format) => {
|
||||
if (activeTab === "performance") {
|
||||
exportPerformance(psi, pages, format);
|
||||
exportPerformance(lighthouse, pages, format);
|
||||
return;
|
||||
}
|
||||
exportPages(pages, format);
|
||||
@ -57,8 +58,13 @@ export function ResultsView({
|
||||
/>
|
||||
|
||||
{activeTab === "pages" && <PagesTable pages={pages} />}
|
||||
{activeTab === "performance" && psi.length > 0 && (
|
||||
<PerformanceTable projectId={projectId} psi={psi} pages={pages} />
|
||||
{activeTab === "performance" && lighthouse.length > 0 && (
|
||||
<PerformanceTable
|
||||
auditId={audit.id}
|
||||
projectId={projectId}
|
||||
lighthouse={lighthouse}
|
||||
pages={pages}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@ -68,28 +74,34 @@ export function ResultsView({
|
||||
|
||||
function useResultStats(
|
||||
pages: AuditResultsData["pages"],
|
||||
psi: AuditResultsData["psi"],
|
||||
lighthouse: AuditResultsData["lighthouse"],
|
||||
) {
|
||||
const averageResponseMs = useMemo(() => {
|
||||
if (pages.length === 0) return 0;
|
||||
const total = pages.reduce(
|
||||
(sum, page) => sum + (page.responseTimeMs ?? 0),
|
||||
(sum: number, page: AuditResultsData["pages"][number]) =>
|
||||
sum + (page.responseTimeMs ?? 0),
|
||||
0,
|
||||
);
|
||||
return Math.round(total / pages.length);
|
||||
}, [pages]);
|
||||
|
||||
const psiSummary = useMemo(() => {
|
||||
const failed = psi.filter((row) => !!row.errorMessage).length;
|
||||
const successful = psi.filter((row) => !row.errorMessage);
|
||||
const lighthouseSummary = useMemo(() => {
|
||||
const failed = lighthouse.filter(
|
||||
(row: AuditResultsData["lighthouse"][number]) => isLighthouseFailure(row),
|
||||
).length;
|
||||
const successful = lighthouse.filter(
|
||||
(row: AuditResultsData["lighthouse"][number]) =>
|
||||
!isLighthouseFailure(row),
|
||||
);
|
||||
const averageScore = (
|
||||
key: "performanceScore" | "seoScore" | "accessibilityScore",
|
||||
) => {
|
||||
const values = successful
|
||||
.map((row) => row[key])
|
||||
.filter((value): value is number => value != null);
|
||||
.map((row: AuditResultsData["lighthouse"][number]) => row[key])
|
||||
.filter((value: number | null): value is number => value != 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);
|
||||
};
|
||||
|
||||
@ -99,21 +111,21 @@ function useResultStats(
|
||||
avgSeo: averageScore("seoScore"),
|
||||
avgAccessibility: averageScore("accessibilityScore"),
|
||||
};
|
||||
}, [psi]);
|
||||
}, [lighthouse]);
|
||||
|
||||
return { averageResponseMs, psiSummary };
|
||||
return { averageResponseMs, lighthouseSummary };
|
||||
}
|
||||
|
||||
function ResultsHeader({
|
||||
pageCount,
|
||||
psiCount,
|
||||
lighthouseCount,
|
||||
hasPerformanceTab,
|
||||
activeTab,
|
||||
setSearchParams,
|
||||
onExport,
|
||||
}: {
|
||||
pageCount: number;
|
||||
psiCount: number;
|
||||
lighthouseCount: number;
|
||||
hasPerformanceTab: boolean;
|
||||
activeTab: string;
|
||||
setSearchParams: SearchSetter;
|
||||
@ -135,7 +147,7 @@ function ResultsHeader({
|
||||
className={`tab ${activeTab === "performance" ? "tab-active" : ""}`}
|
||||
onClick={() => setSearchParams({ tab: "performance" })}
|
||||
>
|
||||
Performance ({psiCount})
|
||||
Performance ({lighthouseCount})
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
@ -150,15 +162,15 @@ function ResultsHeader({
|
||||
function StatsGrid({
|
||||
pagesCrawled,
|
||||
totalPages,
|
||||
totalPsi,
|
||||
totalLighthouse,
|
||||
averageResponseMs,
|
||||
psiSummary,
|
||||
lighthouseSummary,
|
||||
}: {
|
||||
pagesCrawled: number;
|
||||
totalPages: number;
|
||||
totalPsi: number;
|
||||
totalLighthouse: number;
|
||||
averageResponseMs: number;
|
||||
psiSummary: {
|
||||
lighthouseSummary: {
|
||||
failed: number;
|
||||
avgPerformance: number | null;
|
||||
avgSeo: number | null;
|
||||
@ -169,37 +181,43 @@ function StatsGrid({
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<StatCard label="Pages Crawled" value={String(pagesCrawled)} />
|
||||
<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`} />
|
||||
{totalPsi > 0 && (
|
||||
{totalLighthouse > 0 && (
|
||||
<>
|
||||
<StatCard
|
||||
label="Avg PSI Perf"
|
||||
label="Avg Lighthouse Perf"
|
||||
value={
|
||||
psiSummary.avgPerformance == null
|
||||
lighthouseSummary.avgPerformance == null
|
||||
? "-"
|
||||
: String(psiSummary.avgPerformance)
|
||||
: String(lighthouseSummary.avgPerformance)
|
||||
}
|
||||
className={scoreClass(psiSummary.avgPerformance)}
|
||||
className={scoreClass(lighthouseSummary.avgPerformance)}
|
||||
/>
|
||||
<StatCard
|
||||
label="Avg PSI SEO"
|
||||
value={psiSummary.avgSeo == null ? "-" : String(psiSummary.avgSeo)}
|
||||
className={scoreClass(psiSummary.avgSeo)}
|
||||
/>
|
||||
<StatCard
|
||||
label="Avg PSI A11y"
|
||||
label="Avg Lighthouse SEO"
|
||||
value={
|
||||
psiSummary.avgAccessibility == null
|
||||
lighthouseSummary.avgSeo == null
|
||||
? "-"
|
||||
: String(psiSummary.avgAccessibility)
|
||||
: String(lighthouseSummary.avgSeo)
|
||||
}
|
||||
className={scoreClass(psiSummary.avgAccessibility)}
|
||||
className={scoreClass(lighthouseSummary.avgSeo)}
|
||||
/>
|
||||
<StatCard
|
||||
label="PSI Failures"
|
||||
value={String(psiSummary.failed)}
|
||||
className={psiSummary.failed > 0 ? "text-error" : "text-success"}
|
||||
label="Avg Lighthouse A11y"
|
||||
value={
|
||||
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"
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
@ -15,7 +15,7 @@ export function exportPages(
|
||||
pages: AuditResultsData["pages"],
|
||||
format: "csv" | "json",
|
||||
) {
|
||||
const rows = pages.map((page) => ({
|
||||
const rows = pages.map((page: AuditResultsData["pages"][number]) => ({
|
||||
url: page.url,
|
||||
statusCode: page.statusCode,
|
||||
title: page.title ?? "",
|
||||
@ -45,7 +45,7 @@ export function exportPages(
|
||||
"Missing Alt",
|
||||
"Response Time (ms)",
|
||||
];
|
||||
const lines = rows.map((row) => [
|
||||
const lines = rows.map((row: (typeof rows)[number]) => [
|
||||
row.url,
|
||||
row.statusCode,
|
||||
row.title,
|
||||
@ -60,12 +60,16 @@ export function exportPages(
|
||||
}
|
||||
|
||||
export function exportPerformance(
|
||||
psi: AuditResultsData["psi"],
|
||||
lighthouse: AuditResultsData["lighthouse"],
|
||||
pages: AuditResultsData["pages"],
|
||||
format: "csv" | "json",
|
||||
) {
|
||||
const rows = psi.map((result) => {
|
||||
const page = pages.find((candidate) => candidate.id === result.pageId);
|
||||
const rows = lighthouse.map(
|
||||
(result: AuditResultsData["lighthouse"][number]) => {
|
||||
const page = pages.find(
|
||||
(candidate: AuditResultsData["pages"][number]) =>
|
||||
candidate.id === result.pageId,
|
||||
);
|
||||
return {
|
||||
url: page?.url ?? "",
|
||||
strategy: result.strategy,
|
||||
@ -77,7 +81,8 @@ export function exportPerformance(
|
||||
inpMs: result.inpMs,
|
||||
ttfbMs: result.ttfbMs,
|
||||
};
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
if (format === "json") {
|
||||
downloadFile(
|
||||
@ -99,7 +104,7 @@ export function exportPerformance(
|
||||
"INP (ms)",
|
||||
"TTFB (ms)",
|
||||
];
|
||||
const lines = rows.map((row) => [
|
||||
const lines = rows.map((row: (typeof rows)[number]) => [
|
||||
row.url,
|
||||
row.strategy,
|
||||
row.performance,
|
||||
|
||||
@ -70,7 +70,7 @@ export function HttpStatusBadge({ code }: { code: number | null }) {
|
||||
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) {
|
||||
return <span className="text-xs text-base-content/40">-</span>;
|
||||
}
|
||||
|
||||
164
src/client/features/lighthouse/issues/LighthouseIssueRow.tsx
Normal file
164
src/client/features/lighthouse/issues/LighthouseIssueRow.tsx
Normal 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" />;
|
||||
}
|
||||
@ -6,26 +6,33 @@ import {
|
||||
Info,
|
||||
TriangleAlert,
|
||||
} from "lucide-react";
|
||||
import type { CategoryTab, ExportPayload, PsiIssue } from "./types";
|
||||
import {
|
||||
categoryLabel,
|
||||
renderInlineMarkdown,
|
||||
severityBadgeClass,
|
||||
severityIcon,
|
||||
} from "./utils";
|
||||
import type {
|
||||
CategoryTab,
|
||||
ExportPayload,
|
||||
LighthouseIssue,
|
||||
LighthouseMetrics,
|
||||
LighthouseScores,
|
||||
} from "./types";
|
||||
import { LighthouseIssueRow } from "./LighthouseIssueRow";
|
||||
import { LighthouseIssuesSummary } from "./LighthouseIssuesSummary";
|
||||
import { categoryLabel } from "./utils";
|
||||
import { categoryTabs } from "./types";
|
||||
|
||||
export function PsiIssuesHeader({
|
||||
export function LighthouseIssuesHeader({
|
||||
backLabel,
|
||||
onBack,
|
||||
scannedAt,
|
||||
finalUrl,
|
||||
scores,
|
||||
metrics,
|
||||
severityCounts,
|
||||
}: {
|
||||
backLabel: string;
|
||||
onBack: () => void;
|
||||
scannedAt?: string;
|
||||
finalUrl?: string;
|
||||
scores?: LighthouseScores | null;
|
||||
metrics?: LighthouseMetrics | null;
|
||||
severityCounts: { critical: number; warning: number; info: number };
|
||||
}) {
|
||||
return (
|
||||
@ -44,11 +51,12 @@ export function PsiIssuesHeader({
|
||||
<div className="card bg-base-100 border border-base-300">
|
||||
<div className="card-body py-5 gap-4">
|
||||
<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">
|
||||
{finalUrl ?? "Loading URL..."}
|
||||
</p>
|
||||
</div>
|
||||
<LighthouseIssuesSummary scores={scores} metrics={metrics} />
|
||||
<div className="flex flex-wrap gap-2 text-xs">
|
||||
<span className="badge border border-error/30 bg-error/10 text-error/80 gap-1">
|
||||
<FileWarning className="size-3" />
|
||||
@ -69,7 +77,7 @@ export function PsiIssuesHeader({
|
||||
);
|
||||
}
|
||||
|
||||
export function PsiIssuesToolbar({
|
||||
export function LighthouseIssuesToolbar({
|
||||
category,
|
||||
categoryCounts,
|
||||
selectedCategoryLabel,
|
||||
@ -85,12 +93,12 @@ export function PsiIssuesToolbar({
|
||||
categoryCounts: Record<CategoryTab, number>;
|
||||
selectedCategoryLabel: string;
|
||||
isBusy: boolean;
|
||||
visibleIssues: PsiIssue[];
|
||||
allIssues: PsiIssue[];
|
||||
visibleIssues: LighthouseIssue[];
|
||||
allIssues: LighthouseIssue[];
|
||||
onCategoryChange: (next: CategoryTab) => void;
|
||||
onCopy: (data: ExportPayload, toastMessage: string) => void;
|
||||
onExport: (data: ExportPayload) => void;
|
||||
onExportCsv: (issues: PsiIssue[], variant: "all" | "current") => void;
|
||||
onExportCsv: (issues: LighthouseIssue[], variant: "all" | "current") => void;
|
||||
}) {
|
||||
const exportCurrentCategory: ExportPayload =
|
||||
category === "all" ? { mode: "issues" } : { mode: "category", category };
|
||||
@ -161,14 +169,14 @@ function ExportMenu({
|
||||
onExportCsv,
|
||||
visibleIssues,
|
||||
}: {
|
||||
allIssues: PsiIssue[];
|
||||
allIssues: LighthouseIssue[];
|
||||
categoryLabelLower: string;
|
||||
exportCurrentCategory: ExportPayload;
|
||||
isBusy: boolean;
|
||||
onCopy: (data: ExportPayload, toastMessage: string) => void;
|
||||
onExport: (data: ExportPayload) => void;
|
||||
onExportCsv: (issues: PsiIssue[], variant: "all" | "current") => void;
|
||||
visibleIssues: PsiIssue[];
|
||||
onExportCsv: (issues: LighthouseIssue[], variant: "all" | "current") => void;
|
||||
visibleIssues: LighthouseIssue[];
|
||||
}) {
|
||||
return (
|
||||
<div className="dropdown dropdown-end">
|
||||
@ -201,21 +209,23 @@ function ExportMenu({
|
||||
<li>
|
||||
<button
|
||||
disabled={isBusy}
|
||||
onClick={() => onCopy({ mode: "issues" }, "Copied all issues")}
|
||||
onClick={() =>
|
||||
onCopy({ mode: "issues" }, "Copied all actionable issues")
|
||||
}
|
||||
>
|
||||
<Copy className="size-4" />
|
||||
Copy all issues
|
||||
Copy all actionable issues
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
disabled={isBusy}
|
||||
onClick={() =>
|
||||
onCopy({ mode: "full" }, "Copied full Lighthouse report")
|
||||
onCopy({ mode: "full" }, "Copied saved Lighthouse payload")
|
||||
}
|
||||
>
|
||||
<Copy className="size-4" />
|
||||
Copy full Lighthouse report
|
||||
Copy saved Lighthouse payload
|
||||
</button>
|
||||
</li>
|
||||
<li className="menu-title">
|
||||
@ -234,12 +244,12 @@ function ExportMenu({
|
||||
disabled={isBusy}
|
||||
onClick={() => onExport({ mode: "issues" })}
|
||||
>
|
||||
Download all issues
|
||||
Download all actionable issues
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button disabled={isBusy} onClick={() => onExport({ mode: "full" })}>
|
||||
Download full Lighthouse report
|
||||
Download saved Lighthouse payload
|
||||
</button>
|
||||
</li>
|
||||
<li className="menu-title">
|
||||
@ -258,7 +268,7 @@ function ExportMenu({
|
||||
disabled={!allIssues.length}
|
||||
onClick={() => onExportCsv(allIssues, "all")}
|
||||
>
|
||||
Download all issues
|
||||
Download all actionable issues
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
@ -266,12 +276,14 @@ function ExportMenu({
|
||||
);
|
||||
}
|
||||
|
||||
export function PsiIssueList({
|
||||
export function LighthouseIssueList({
|
||||
issues,
|
||||
isLoading,
|
||||
emptyMessage,
|
||||
}: {
|
||||
issues: PsiIssue[];
|
||||
issues: LighthouseIssue[];
|
||||
isLoading: boolean;
|
||||
emptyMessage?: string;
|
||||
}) {
|
||||
if (isLoading) {
|
||||
return <p className="text-sm text-base-content/60">Loading issues...</p>;
|
||||
@ -279,84 +291,40 @@ export function PsiIssueList({
|
||||
if (!issues.length) {
|
||||
return (
|
||||
<p className="text-sm text-base-content/60">
|
||||
No unresolved issues for this category.
|
||||
{emptyMessage ?? "No actionable issues for this category."}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{issues.map((issue) => (
|
||||
<PsiIssueCard
|
||||
key={`${issue.category}-${issue.auditKey}`}
|
||||
<table className="table table-sm w-full table-fixed">
|
||||
<colgroup>
|
||||
<col className="w-8" />
|
||||
<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}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,11 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { AlertCircle, TriangleAlert } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { exportAuditPsi, getAuditPsiIssues } from "@/serverFunctions/psi";
|
||||
import type { CategoryTab, ExportPayload, PsiIssue } from "./types";
|
||||
import {
|
||||
exportAuditLighthouseIssues,
|
||||
getAuditLighthouseIssues,
|
||||
} from "@/serverFunctions/lighthouse";
|
||||
import type { CategoryTab, ExportPayload, LighthouseIssue } from "./types";
|
||||
import {
|
||||
categoryLabel,
|
||||
categorySlug,
|
||||
@ -9,13 +13,13 @@ import {
|
||||
issuesToCsv,
|
||||
} from "./utils";
|
||||
import {
|
||||
PsiIssueList,
|
||||
PsiIssuesHeader,
|
||||
PsiIssuesToolbar,
|
||||
} from "./PsiIssuesParts";
|
||||
LighthouseIssueList,
|
||||
LighthouseIssuesHeader,
|
||||
LighthouseIssuesToolbar,
|
||||
} from "./LighthouseIssuesParts";
|
||||
import { categoryTabs } from "./types";
|
||||
|
||||
type PsiIssuesScreenProps = {
|
||||
type LighthouseIssuesScreenProps = {
|
||||
projectId: string;
|
||||
resultId: string;
|
||||
category: CategoryTab;
|
||||
@ -24,26 +28,14 @@ type PsiIssuesScreenProps = {
|
||||
onCategoryChange: (next: CategoryTab) => void;
|
||||
};
|
||||
|
||||
export function PsiIssuesScreen(props: PsiIssuesScreenProps) {
|
||||
export function LighthouseIssuesScreen(props: LighthouseIssuesScreenProps) {
|
||||
const { projectId, resultId, category, backLabel, onBack, onCategoryChange } =
|
||||
props;
|
||||
|
||||
const issuesQuery = useQuery({
|
||||
queryKey: ["auditPsiIssues", projectId, resultId, category],
|
||||
queryKey: ["auditLighthouseIssues", projectId, resultId],
|
||||
queryFn: () =>
|
||||
getAuditPsiIssues({
|
||||
data: {
|
||||
projectId,
|
||||
resultId,
|
||||
category: category === "all" ? undefined : category,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const summaryQuery = useQuery({
|
||||
queryKey: ["auditPsiIssuesSummary", projectId, resultId],
|
||||
queryFn: () =>
|
||||
getAuditPsiIssues({
|
||||
getAuditLighthouseIssues({
|
||||
data: {
|
||||
projectId,
|
||||
resultId,
|
||||
@ -52,8 +44,10 @@ export function PsiIssuesScreen(props: PsiIssuesScreenProps) {
|
||||
});
|
||||
|
||||
const exportMutation = useMutation({
|
||||
mutationFn: (data: ExportPayload) =>
|
||||
exportAuditPsi({
|
||||
mutationFn: (
|
||||
data: ExportPayload,
|
||||
): Promise<{ filename: string; content: string }> =>
|
||||
exportAuditLighthouseIssues({
|
||||
data: {
|
||||
projectId,
|
||||
resultId,
|
||||
@ -71,27 +65,56 @@ export function PsiIssuesScreen(props: PsiIssuesScreenProps) {
|
||||
selectedCategoryLabel,
|
||||
severityCounts,
|
||||
visibleIssues,
|
||||
} = usePsiIssuesActions({
|
||||
} = useLighthouseIssuesActions({
|
||||
category,
|
||||
exportMutation,
|
||||
issues: (issuesQuery.data?.issues ?? []) as PsiIssue[],
|
||||
summaryIssues: summaryQuery.data?.issues,
|
||||
allIssues: issuesQuery.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 (
|
||||
<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">
|
||||
<PsiIssuesHeader
|
||||
<LighthouseIssuesHeader
|
||||
backLabel={backLabel}
|
||||
onBack={onBack}
|
||||
scannedAt={issuesQuery.data?.createdAt}
|
||||
finalUrl={issuesQuery.data?.finalUrl}
|
||||
scores={issuesQuery.data?.scores}
|
||||
metrics={issuesQuery.data?.metrics}
|
||||
severityCounts={severityCounts}
|
||||
/>
|
||||
|
||||
<div className="card bg-base-100 border border-base-300">
|
||||
<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}
|
||||
categoryCounts={categoryCounts}
|
||||
selectedCategoryLabel={selectedCategoryLabel}
|
||||
@ -107,9 +130,10 @@ export function PsiIssuesScreen(props: PsiIssuesScreenProps) {
|
||||
}}
|
||||
onExportCsv={runExportCsv}
|
||||
/>
|
||||
<PsiIssueList
|
||||
<LighthouseIssueList
|
||||
issues={visibleIssues}
|
||||
isLoading={issuesQuery.isLoading}
|
||||
emptyMessage={emptyMessage}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -118,23 +142,23 @@ export function PsiIssuesScreen(props: PsiIssuesScreenProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function usePsiIssuesActions({
|
||||
function useLighthouseIssuesActions({
|
||||
allIssues,
|
||||
category,
|
||||
exportMutation,
|
||||
issues,
|
||||
summaryIssues,
|
||||
}: {
|
||||
allIssues: LighthouseIssue[];
|
||||
category: CategoryTab;
|
||||
exportMutation: {
|
||||
mutateAsync: (
|
||||
data: ExportPayload,
|
||||
) => Promise<{ filename: string; content: string }>;
|
||||
};
|
||||
issues: PsiIssue[];
|
||||
summaryIssues: PsiIssue[] | undefined;
|
||||
}) {
|
||||
const visibleIssues = issues;
|
||||
const allIssues = summaryIssues ?? visibleIssues;
|
||||
const visibleIssues =
|
||||
category === "all"
|
||||
? allIssues
|
||||
: allIssues.filter((issue) => issue.category === category);
|
||||
const selectedCategoryLabel = categoryLabel(category);
|
||||
const categoryCounts = getCategoryCounts(allIssues);
|
||||
const severityCounts = getSeverityCounts(visibleIssues);
|
||||
@ -151,8 +175,11 @@ function usePsiIssuesActions({
|
||||
}
|
||||
};
|
||||
|
||||
const runExportCsv = (rows: PsiIssue[], variant: "all" | "current") => {
|
||||
const filename = `psi-${variant}-${categorySlug(category)}-issues.csv`;
|
||||
const runExportCsv = (
|
||||
rows: LighthouseIssue[],
|
||||
variant: "all" | "current",
|
||||
) => {
|
||||
const filename = `lighthouse-${variant}-${categorySlug(category)}-issues.csv`;
|
||||
downloadTextFile(filename, issuesToCsv(rows), "text/csv");
|
||||
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>>(
|
||||
(acc, tab) => {
|
||||
if (tab === "all") {
|
||||
@ -201,7 +230,7 @@ function getCategoryCounts(allIssues: PsiIssue[]): Record<CategoryTab, number> {
|
||||
);
|
||||
}
|
||||
|
||||
function getSeverityCounts(issues: PsiIssue[]) {
|
||||
function getSeverityCounts(issues: LighthouseIssue[]) {
|
||||
return {
|
||||
critical: issues.filter((issue) => issue.severity === "critical").length,
|
||||
warning: issues.filter((issue) => issue.severity === "warning").length,
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
26
src/client/features/lighthouse/issues/types.ts
Normal file
26
src/client/features/lighthouse/issues/types.ts
Normal 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"]
|
||||
>;
|
||||
53
src/client/features/lighthouse/issues/utils.tsx
Normal file
53
src/client/features/lighthouse/issues/utils.tsx
Normal 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);
|
||||
}
|
||||
@ -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[];
|
||||
};
|
||||
@ -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" />;
|
||||
}
|
||||
@ -27,9 +27,6 @@ export const projects = sqliteTable("projects", {
|
||||
.references(() => organization.id, { onDelete: "cascade" }),
|
||||
name: text("name").notNull(),
|
||||
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")
|
||||
.notNull()
|
||||
.default(sql`(current_timestamp)`),
|
||||
@ -123,14 +120,14 @@ export const audits = sqliteTable(
|
||||
.notNull()
|
||||
.default("running"),
|
||||
workflowInstanceId: text("workflow_instance_id"),
|
||||
// JSON config: { maxPages, psiStrategy, psiApiKey? }
|
||||
// JSON config: { maxPages, lighthouseStrategy }
|
||||
config: text("config").notNull().default("{}"),
|
||||
// Progress & summary
|
||||
pagesCrawled: integer("pages_crawled").notNull().default(0),
|
||||
pagesTotal: integer("pages_total").notNull().default(0),
|
||||
psiTotal: integer("psi_total").notNull().default(0),
|
||||
psiCompleted: integer("psi_completed").notNull().default(0),
|
||||
psiFailed: integer("psi_failed").notNull().default(0),
|
||||
lighthouseTotal: integer("lighthouse_total").notNull().default(0),
|
||||
lighthouseCompleted: integer("lighthouse_completed").notNull().default(0),
|
||||
lighthouseFailed: integer("lighthouse_failed").notNull().default(0),
|
||||
currentPhase: text("current_phase").default("discovery"),
|
||||
startedAt: text("started_at")
|
||||
.notNull()
|
||||
@ -196,10 +193,9 @@ export const auditPages = sqliteTable(
|
||||
(table) => [index("audit_pages_audit_id_idx").on(table.auditId)],
|
||||
);
|
||||
|
||||
// PSI summaries captured as part of a site audit run.
|
||||
// These belong to audit pages and are the only PSI result records we keep.
|
||||
export const auditPsiResults = sqliteTable(
|
||||
"audit_psi_results",
|
||||
// One row per Lighthouse test (mobile + desktop per page).
|
||||
export const auditLighthouseResults = sqliteTable(
|
||||
"audit_lighthouse_results",
|
||||
{
|
||||
id: text("id").primaryKey(),
|
||||
auditId: text("audit_id")
|
||||
@ -221,5 +217,5 @@ export const auditPsiResults = sqliteTable(
|
||||
r2Key: text("r2_key"),
|
||||
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)],
|
||||
);
|
||||
|
||||
@ -199,9 +199,9 @@ function ProgressCard({
|
||||
status: {
|
||||
pagesCrawled: number;
|
||||
pagesTotal: number;
|
||||
psiTotal: number;
|
||||
psiCompleted: number;
|
||||
psiFailed: number;
|
||||
lighthouseTotal: number;
|
||||
lighthouseCompleted: number;
|
||||
lighthouseFailed: number;
|
||||
currentPhase: string | null;
|
||||
};
|
||||
}) {
|
||||
@ -209,21 +209,23 @@ function ProgressCard({
|
||||
status.pagesTotal > 0
|
||||
? Math.round((status.pagesCrawled / status.pagesTotal) * 100)
|
||||
: 0;
|
||||
const psiDone = status.psiCompleted + status.psiFailed;
|
||||
const psiProgress =
|
||||
status.psiTotal > 0 ? Math.round((psiDone / status.psiTotal) * 100) : 0;
|
||||
const isPsiPhase = status.currentPhase === "psi";
|
||||
const lighthouseDone = status.lighthouseCompleted + status.lighthouseFailed;
|
||||
const lighthouseProgress =
|
||||
status.lighthouseTotal > 0
|
||||
? Math.round((lighthouseDone / status.lighthouseTotal) * 100)
|
||||
: 0;
|
||||
const isLighthousePhase = status.currentPhase === "lighthouse";
|
||||
const phaseLabel =
|
||||
status.currentPhase === "discovery"
|
||||
? "Discovery"
|
||||
: status.currentPhase === "crawling"
|
||||
? "Crawling"
|
||||
: status.currentPhase === "psi"
|
||||
? "PSI"
|
||||
: status.currentPhase === "lighthouse"
|
||||
? "Lighthouse"
|
||||
: status.currentPhase === "finalizing"
|
||||
? "Finalizing"
|
||||
: (status.currentPhase ?? "Running");
|
||||
const progress = isPsiPhase ? psiProgress : crawlProgress;
|
||||
const progress = isLighthousePhase ? lighthouseProgress : crawlProgress;
|
||||
|
||||
const crawlProgressQuery = useQuery({
|
||||
queryKey: ["audit-crawl-progress", projectId, auditId],
|
||||
@ -240,7 +242,9 @@ function ProgressCard({
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="font-medium flex items-center gap-2">
|
||||
<Loader2 className="size-4 animate-spin text-primary" />
|
||||
{isPsiPhase ? "Running PSI checks" : "Crawling pages"}
|
||||
{isLighthousePhase
|
||||
? "Running Lighthouse checks"
|
||||
: "Crawling pages"}
|
||||
</h2>
|
||||
<span className="badge badge-ghost badge-sm">{phaseLabel}</span>
|
||||
</div>
|
||||
@ -252,10 +256,12 @@ function ProgressCard({
|
||||
/>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
{isPsiPhase ? (
|
||||
{isLighthousePhase ? (
|
||||
<span>
|
||||
{psiDone} / {status.psiTotal} checks
|
||||
{status.psiFailed > 0 ? ` (${status.psiFailed} failed)` : ""}
|
||||
{lighthouseDone} / {status.lighthouseTotal} checks
|
||||
{status.lighthouseFailed > 0
|
||||
? ` (${status.lighthouseFailed} failed)`
|
||||
: ""}
|
||||
</span>
|
||||
) : (
|
||||
<span>
|
||||
|
||||
@ -1,21 +1,21 @@
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { PsiIssuesScreen } from "@/client/features/psi/issues/PsiIssuesScreen";
|
||||
import { psiIssuesSearchSchema } from "@/types/schemas/psi";
|
||||
import { LighthouseIssuesScreen } from "@/client/features/lighthouse/issues/LighthouseIssuesScreen";
|
||||
import { lighthouseIssuesSearchSchema } from "@/types/schemas/lighthouse";
|
||||
|
||||
export const Route = createFileRoute(
|
||||
"/_project/p/$projectId/audit/issues/$resultId",
|
||||
)({
|
||||
validateSearch: psiIssuesSearchSchema,
|
||||
validateSearch: lighthouseIssuesSearchSchema,
|
||||
component: AuditIssuesPage,
|
||||
});
|
||||
|
||||
function AuditIssuesPage() {
|
||||
const { projectId, resultId } = Route.useParams();
|
||||
const { category } = Route.useSearch();
|
||||
const { auditId, category } = Route.useSearch();
|
||||
const navigate = useNavigate({ from: Route.fullPath });
|
||||
|
||||
return (
|
||||
<PsiIssuesScreen
|
||||
<LighthouseIssuesScreen
|
||||
projectId={projectId}
|
||||
resultId={resultId}
|
||||
category={category}
|
||||
@ -24,6 +24,7 @@ function AuditIssuesPage() {
|
||||
void navigate({
|
||||
to: "/p/$projectId/audit",
|
||||
params: { projectId },
|
||||
search: auditId ? { auditId } : undefined,
|
||||
})
|
||||
}
|
||||
onCategoryChange={(next) =>
|
||||
|
||||
@ -1,13 +1,30 @@
|
||||
/**
|
||||
* 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 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: {
|
||||
id: string;
|
||||
@ -17,7 +34,7 @@ async function createAudit(data: {
|
||||
workflowInstanceId: string;
|
||||
config: AuditConfig;
|
||||
pagesTotal: number;
|
||||
psiTotal: number;
|
||||
lighthouseTotal: number;
|
||||
}) {
|
||||
await db.insert(audits).values({
|
||||
id: data.id,
|
||||
@ -28,22 +45,20 @@ async function createAudit(data: {
|
||||
config: JSON.stringify(data.config),
|
||||
status: "running",
|
||||
pagesTotal: data.pagesTotal,
|
||||
psiTotal: data.psiTotal,
|
||||
lighthouseTotal: data.lighthouseTotal,
|
||||
currentPhase: "discovery",
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Update ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function updateAuditProgress(
|
||||
auditId: string,
|
||||
workflowInstanceId: string,
|
||||
data: {
|
||||
pagesCrawled?: number;
|
||||
pagesTotal?: number;
|
||||
psiTotal?: number;
|
||||
psiCompleted?: number;
|
||||
psiFailed?: number;
|
||||
lighthouseTotal?: number;
|
||||
lighthouseCompleted?: number;
|
||||
lighthouseFailed?: number;
|
||||
currentPhase?: string;
|
||||
},
|
||||
) {
|
||||
@ -110,131 +125,69 @@ 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(
|
||||
auditId: string,
|
||||
pages: Array<{
|
||||
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;
|
||||
}>,
|
||||
psiResults: PsiResult[],
|
||||
pages: StepPageResult[],
|
||||
lighthouseResults: LighthouseResult[],
|
||||
) {
|
||||
const BATCH_SIZE = 100; // D1 max statements per batch() call
|
||||
|
||||
// ── Pages ──────────────────────────────────────────────────────────
|
||||
const pageStatements = pages.map((p) =>
|
||||
await executeInBatches(pages, (page) =>
|
||||
db.insert(auditPages).values({
|
||||
id: p.id,
|
||||
id: page.id,
|
||||
auditId,
|
||||
url: p.url,
|
||||
statusCode: p.statusCode,
|
||||
redirectUrl: p.redirectUrl,
|
||||
// Metadata
|
||||
title: p.title,
|
||||
metaDescription: p.metaDescription,
|
||||
canonicalUrl: p.canonicalUrl,
|
||||
robotsMeta: p.robotsMeta,
|
||||
// Open Graph
|
||||
ogTitle: p.ogTitle,
|
||||
ogDescription: p.ogDescription,
|
||||
ogImage: p.ogImage,
|
||||
// Headings
|
||||
h1Count: p.h1Count,
|
||||
h2Count: p.h2Count,
|
||||
h3Count: p.h3Count,
|
||||
h4Count: p.h4Count,
|
||||
h5Count: p.h5Count,
|
||||
h6Count: p.h6Count,
|
||||
headingOrderJson: JSON.stringify(p.headingOrder),
|
||||
// Content
|
||||
wordCount: p.wordCount,
|
||||
// Images
|
||||
imagesTotal: p.imagesTotal,
|
||||
imagesMissingAlt: p.imagesMissingAlt,
|
||||
imagesJson: JSON.stringify(p.images),
|
||||
// Links
|
||||
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,
|
||||
url: page.url,
|
||||
statusCode: page.statusCode,
|
||||
redirectUrl: page.redirectUrl,
|
||||
title: page.title,
|
||||
metaDescription: page.metaDescription,
|
||||
canonicalUrl: page.canonicalUrl,
|
||||
robotsMeta: page.robotsMeta,
|
||||
ogTitle: page.ogTitle,
|
||||
ogDescription: page.ogDescription,
|
||||
ogImage: page.ogImage,
|
||||
h1Count: page.h1Count,
|
||||
h2Count: page.h2Count,
|
||||
h3Count: page.h3Count,
|
||||
h4Count: page.h4Count,
|
||||
h5Count: page.h5Count,
|
||||
h6Count: page.h6Count,
|
||||
headingOrderJson: JSON.stringify(page.headingOrder),
|
||||
wordCount: page.wordCount,
|
||||
imagesTotal: page.imagesTotal,
|
||||
imagesMissingAlt: page.imagesMissingAlt,
|
||||
imagesJson: JSON.stringify(page.images),
|
||||
internalLinkCount: page.internalLinks.length,
|
||||
externalLinkCount: page.externalLinks.length,
|
||||
hasStructuredData: page.hasStructuredData,
|
||||
hreflangTagsJson: JSON.stringify(page.hreflangTags),
|
||||
isIndexable: page.isIndexable,
|
||||
responseTimeMs: page.responseTimeMs,
|
||||
}),
|
||||
);
|
||||
|
||||
for (let i = 0; i < pageStatements.length; i += BATCH_SIZE) {
|
||||
const chunk = pageStatements.slice(i, i + BATCH_SIZE);
|
||||
const [first, ...rest] = chunk;
|
||||
await db.batch([first, ...rest]);
|
||||
if (lighthouseResults.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// ── PSI results ────────────────────────────────────────────────────
|
||||
if (psiResults.length > 0) {
|
||||
const psiStatements = psiResults.map((r) =>
|
||||
db.insert(auditPsiResults).values({
|
||||
await executeInBatches(lighthouseResults, (result) =>
|
||||
db.insert(auditLighthouseResults).values({
|
||||
id: crypto.randomUUID(),
|
||||
auditId,
|
||||
pageId: r.pageId,
|
||||
strategy: r.strategy,
|
||||
performanceScore: r.performanceScore,
|
||||
accessibilityScore: r.accessibilityScore,
|
||||
bestPracticesScore: r.bestPracticesScore,
|
||||
seoScore: r.seoScore,
|
||||
lcpMs: r.lcpMs,
|
||||
cls: r.cls,
|
||||
inpMs: r.inpMs,
|
||||
ttfbMs: r.ttfbMs,
|
||||
errorMessage: r.errorMessage ?? null,
|
||||
r2Key: r.r2Key ?? null,
|
||||
payloadSizeBytes: r.payloadSizeBytes ?? null,
|
||||
pageId: result.pageId,
|
||||
strategy: result.strategy,
|
||||
performanceScore: result.performanceScore,
|
||||
accessibilityScore: result.accessibilityScore,
|
||||
bestPracticesScore: result.bestPracticesScore,
|
||||
seoScore: result.seoScore,
|
||||
lcpMs: result.lcpMs,
|
||||
cls: result.cls,
|
||||
inpMs: result.inpMs,
|
||||
ttfbMs: result.ttfbMs,
|
||||
errorMessage: result.errorMessage ?? null,
|
||||
r2Key: result.r2Key ?? 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) {
|
||||
return db.query.audits.findFirst({
|
||||
@ -252,78 +205,80 @@ async function getAuditsByProject(projectId: string) {
|
||||
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) {
|
||||
const rows = await db.query.audits.findMany({
|
||||
where: eq(audits.startedByUserId, userId),
|
||||
columns: {
|
||||
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: {
|
||||
psiResultId: string;
|
||||
async function getAuditResultsForProject(auditId: string, projectId: string) {
|
||||
const audit = await getAuditForProject(auditId, projectId);
|
||||
if (!audit) {
|
||||
return { audit: null, pages: [], lighthouse: [] };
|
||||
}
|
||||
|
||||
const [pages, lighthouse] = await Promise.all([
|
||||
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 psi = await db.query.auditPsiResults.findFirst({
|
||||
where: eq(auditPsiResults.id, input.psiResultId),
|
||||
const lighthouse = await db.query.auditLighthouseResults.findFirst({
|
||||
where: eq(auditLighthouseResults.id, input.lighthouseResultId),
|
||||
});
|
||||
|
||||
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");
|
||||
if (!lighthouse) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const page = await db.query.auditPages.findFirst({
|
||||
where: eq(auditPages.id, psi.pageId),
|
||||
});
|
||||
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 {
|
||||
psi,
|
||||
lighthouse,
|
||||
page,
|
||||
audit: parentAudit,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Delete ──────────────────────────────────────────────────────────────────
|
||||
|
||||
async function deleteAuditForProject(auditId: string, projectId: string) {
|
||||
await db
|
||||
.delete(audits)
|
||||
.where(and(eq(audits.id, auditId), eq(audits.projectId, projectId)));
|
||||
}
|
||||
|
||||
// ─── Export ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export const AuditRepository = {
|
||||
createAudit,
|
||||
updateAuditProgress,
|
||||
@ -333,8 +288,8 @@ export const AuditRepository = {
|
||||
batchWriteResults,
|
||||
getAuditForProject,
|
||||
getAuditsByProject,
|
||||
getAuditResultsForProject,
|
||||
getAuditCapacityUsageForUser,
|
||||
getPsiResultById,
|
||||
getAuditResultsForProject,
|
||||
getLighthouseResultById,
|
||||
deleteAuditForProject,
|
||||
} as const;
|
||||
|
||||
@ -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 type { BillingCustomerContext } from "@/server/billing/subscription";
|
||||
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 {
|
||||
MAX_USER_AUDIT_USAGE,
|
||||
clampAuditMaxPages,
|
||||
getEstimatedAuditCapacity,
|
||||
MAX_USER_AUDIT_USAGE,
|
||||
} from "@/server/features/audit/services/audit-capacity";
|
||||
import { jsonCodec } from "@/shared/json";
|
||||
import { z } from "zod";
|
||||
|
||||
const auditConfigSchema = z.object({
|
||||
maxPages: z.number().int().min(10).max(10_000),
|
||||
psiStrategy: z.enum(["auto", "all", "manual", "none"]),
|
||||
psiApiKey: z.string().optional(),
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
import { AppError } from "@/server/lib/errors";
|
||||
import { AuditProgressKV } from "@/server/lib/audit/progress-kv";
|
||||
import {
|
||||
parseAuditConfig,
|
||||
type AuditConfig,
|
||||
type LighthouseStrategy,
|
||||
} from "@/server/lib/audit/types";
|
||||
import { normalizeAndValidateStartUrl } from "@/server/lib/audit/url-policy";
|
||||
|
||||
async function startAudit(input: {
|
||||
actorUserId: string;
|
||||
billingCustomer: BillingCustomerContext;
|
||||
projectId: string;
|
||||
startUrl: string;
|
||||
maxPages?: number;
|
||||
psiStrategy?: PsiStrategy;
|
||||
psiApiKey?: string;
|
||||
lighthouseStrategy?: LighthouseStrategy;
|
||||
}) {
|
||||
const maxPages = clampAuditMaxPages(input.maxPages);
|
||||
const psiStrategy = input.psiStrategy ?? "auto";
|
||||
|
||||
const lighthouseStrategy = input.lighthouseStrategy ?? "auto";
|
||||
const reservation = getEstimatedAuditCapacity({
|
||||
maxPages,
|
||||
psiStrategy,
|
||||
lighthouseStrategy,
|
||||
});
|
||||
|
||||
const currentUsage = await AuditRepository.getAuditCapacityUsageForUser(
|
||||
@ -56,27 +39,7 @@ async function startAudit(input: {
|
||||
}
|
||||
|
||||
const auditId = crypto.randomUUID();
|
||||
|
||||
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 config: AuditConfig = { maxPages, lighthouseStrategy };
|
||||
const startUrl = await normalizeAndValidateStartUrl(input.startUrl);
|
||||
|
||||
await AuditRepository.createAudit({
|
||||
@ -87,15 +50,15 @@ async function startAudit(input: {
|
||||
workflowInstanceId: auditId,
|
||||
config,
|
||||
pagesTotal: reservation.pagesTotal,
|
||||
psiTotal: reservation.psiTotal,
|
||||
lighthouseTotal: reservation.lighthouseTotal,
|
||||
});
|
||||
|
||||
// Trigger the Cloudflare Workflow
|
||||
try {
|
||||
await env.SITE_AUDIT_WORKFLOW.create({
|
||||
id: auditId,
|
||||
params: {
|
||||
auditId,
|
||||
billingCustomer: input.billingCustomer,
|
||||
projectId: input.projectId,
|
||||
startUrl,
|
||||
config,
|
||||
@ -108,6 +71,7 @@ async function startAudit(input: {
|
||||
} catch {
|
||||
// The workflow may never have been created, or may already be gone.
|
||||
}
|
||||
|
||||
await AuditRepository.deleteAuditForProject(auditId, input.projectId);
|
||||
throw error;
|
||||
}
|
||||
@ -125,9 +89,9 @@ async function getStatus(auditId: string, projectId: string) {
|
||||
status: audit.status,
|
||||
pagesCrawled: audit.pagesCrawled,
|
||||
pagesTotal: audit.pagesTotal,
|
||||
psiTotal: audit.psiTotal,
|
||||
psiCompleted: audit.psiCompleted,
|
||||
psiFailed: audit.psiFailed,
|
||||
lighthouseTotal: audit.lighthouseTotal,
|
||||
lighthouseCompleted: audit.lighthouseCompleted,
|
||||
lighthouseFailed: audit.lighthouseFailed,
|
||||
currentPhase: audit.currentPhase,
|
||||
startedAt: audit.startedAt,
|
||||
completedAt: audit.completedAt,
|
||||
@ -135,10 +99,8 @@ async function getStatus(auditId: string, projectId: string) {
|
||||
}
|
||||
|
||||
async function getResults(auditId: string, projectId: string) {
|
||||
const { audit, pages, psi } = await AuditRepository.getAuditResultsForProject(
|
||||
auditId,
|
||||
projectId,
|
||||
);
|
||||
const { audit, pages, lighthouse } =
|
||||
await AuditRepository.getAuditResultsForProject(auditId, projectId);
|
||||
|
||||
if (!audit) throw new AppError("NOT_FOUND");
|
||||
|
||||
@ -146,7 +108,6 @@ async function getResults(auditId: string, projectId: string) {
|
||||
if (!parsedConfig) {
|
||||
throw new AppError("INTERNAL_ERROR", "Invalid audit configuration");
|
||||
}
|
||||
const { psiApiKey: _psiApiKey, ...safeConfig } = parsedConfig;
|
||||
|
||||
return {
|
||||
audit: {
|
||||
@ -157,31 +118,31 @@ async function getResults(auditId: string, projectId: string) {
|
||||
pagesTotal: audit.pagesTotal,
|
||||
startedAt: audit.startedAt,
|
||||
completedAt: audit.completedAt,
|
||||
config: safeConfig,
|
||||
config: parsedConfig,
|
||||
},
|
||||
pages,
|
||||
psi,
|
||||
lighthouse,
|
||||
};
|
||||
}
|
||||
|
||||
async function getHistory(projectId: string) {
|
||||
const auditList = await AuditRepository.getAuditsByProject(projectId);
|
||||
|
||||
const didRunPsi = (configRaw: string | null) => {
|
||||
const parsed = parseAuditConfig(configRaw);
|
||||
return parsed?.psiStrategy != null && parsed.psiStrategy !== "none";
|
||||
};
|
||||
return auditList.map((audit) => {
|
||||
const parsedConfig = parseAuditConfig(audit.config);
|
||||
const ranLighthouse = parsedConfig?.lighthouseStrategy !== "none";
|
||||
|
||||
return auditList.map((a) => ({
|
||||
id: a.id,
|
||||
startUrl: a.startUrl,
|
||||
status: a.status,
|
||||
pagesCrawled: a.pagesCrawled,
|
||||
pagesTotal: a.pagesTotal,
|
||||
ranPsi: didRunPsi(a.config),
|
||||
startedAt: a.startedAt,
|
||||
completedAt: a.completedAt,
|
||||
}));
|
||||
return {
|
||||
id: audit.id,
|
||||
startUrl: audit.startUrl,
|
||||
status: audit.status,
|
||||
pagesCrawled: audit.pagesCrawled,
|
||||
pagesTotal: audit.pagesTotal,
|
||||
ranLighthouse,
|
||||
startedAt: audit.startedAt,
|
||||
completedAt: audit.completedAt,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function getCrawlProgress(auditId: string, projectId: string) {
|
||||
@ -189,6 +150,7 @@ async function getCrawlProgress(auditId: string, projectId: string) {
|
||||
if (!audit) {
|
||||
throw new AppError("NOT_FOUND");
|
||||
}
|
||||
|
||||
return AuditProgressKV.getCrawledUrls(auditId);
|
||||
}
|
||||
|
||||
@ -197,6 +159,7 @@ async function remove(auditId: string, projectId: string) {
|
||||
if (!audit) {
|
||||
throw new AppError("NOT_FOUND");
|
||||
}
|
||||
|
||||
if (audit.status === "running") {
|
||||
if (!audit.workflowInstanceId) {
|
||||
throw new AppError(
|
||||
@ -215,6 +178,7 @@ async function remove(auditId: string, projectId: string) {
|
||||
throw new AppError("CONFLICT", "Unable to stop the running audit.");
|
||||
}
|
||||
}
|
||||
|
||||
await AuditRepository.deleteAuditForProject(auditId, projectId);
|
||||
}
|
||||
|
||||
|
||||
@ -13,41 +13,46 @@ describe("audit capacity helpers", () => {
|
||||
expect(clampAuditMaxPages(20_000)).toBe(10_000);
|
||||
});
|
||||
|
||||
it("estimates capacity for each psi strategy", () => {
|
||||
it("estimates capacity for each lighthouse strategy", () => {
|
||||
expect(
|
||||
getEstimatedAuditCapacity({ maxPages: 100, psiStrategy: "none" }),
|
||||
getEstimatedAuditCapacity({ maxPages: 100, lighthouseStrategy: "none" }),
|
||||
).toEqual({
|
||||
pagesTotal: 100,
|
||||
psiTotal: 0,
|
||||
lighthouseTotal: 0,
|
||||
total: 100,
|
||||
});
|
||||
expect(
|
||||
getEstimatedAuditCapacity({ maxPages: 100, psiStrategy: "manual" }),
|
||||
getEstimatedAuditCapacity({
|
||||
maxPages: 100,
|
||||
lighthouseStrategy: "manual",
|
||||
}),
|
||||
).toEqual({
|
||||
pagesTotal: 100,
|
||||
psiTotal: 0,
|
||||
lighthouseTotal: 0,
|
||||
total: 100,
|
||||
});
|
||||
expect(
|
||||
getEstimatedAuditCapacity({ maxPages: 100, psiStrategy: "auto" }),
|
||||
getEstimatedAuditCapacity({ maxPages: 100, lighthouseStrategy: "auto" }),
|
||||
).toEqual({
|
||||
pagesTotal: 100,
|
||||
psiTotal: 20,
|
||||
lighthouseTotal: 20,
|
||||
total: 120,
|
||||
});
|
||||
expect(
|
||||
getEstimatedAuditCapacity({ maxPages: 100, psiStrategy: "all" }),
|
||||
getEstimatedAuditCapacity({ maxPages: 100, lighthouseStrategy: "all" }),
|
||||
).toEqual({
|
||||
pagesTotal: 100,
|
||||
psiTotal: 200,
|
||||
lighthouseTotal: 200,
|
||||
total: 300,
|
||||
});
|
||||
});
|
||||
|
||||
it("stays within the global capacity limit for the maximum auto audit", () => {
|
||||
expect(
|
||||
getEstimatedAuditCapacity({ maxPages: 10_000, psiStrategy: "auto" })
|
||||
.total,
|
||||
getEstimatedAuditCapacity({
|
||||
maxPages: 10_000,
|
||||
lighthouseStrategy: "auto",
|
||||
}).total,
|
||||
).toBeLessThan(MAX_USER_AUDIT_USAGE);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -8,28 +8,28 @@ export function clampAuditMaxPages(maxPages?: number) {
|
||||
|
||||
export function getEstimatedAuditCapacity(input: {
|
||||
maxPages?: number;
|
||||
psiStrategy?: PsiStrategy;
|
||||
lighthouseStrategy?: LighthouseStrategy;
|
||||
}) {
|
||||
const pagesTotal = clampAuditMaxPages(input.maxPages);
|
||||
const psiStrategy = input.psiStrategy ?? "auto";
|
||||
const lighthouseStrategy = input.lighthouseStrategy ?? "auto";
|
||||
|
||||
let psiTotal = 0;
|
||||
switch (psiStrategy) {
|
||||
let lighthouseChecks = 0;
|
||||
switch (lighthouseStrategy) {
|
||||
case "all":
|
||||
psiTotal = pagesTotal * 2;
|
||||
lighthouseChecks = pagesTotal * 2;
|
||||
break;
|
||||
case "auto":
|
||||
psiTotal = 20;
|
||||
lighthouseChecks = 20;
|
||||
break;
|
||||
case "manual":
|
||||
case "none":
|
||||
psiTotal = 0;
|
||||
lighthouseChecks = 0;
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
pagesTotal,
|
||||
psiTotal,
|
||||
total: pagesTotal + psiTotal,
|
||||
lighthouseTotal: lighthouseChecks,
|
||||
total: pagesTotal + lighthouseChecks,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@ -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(
|
||||
organizationId: string,
|
||||
name: string,
|
||||
@ -85,9 +63,6 @@ export const ProjectRepository = {
|
||||
listProjects,
|
||||
getProjectForOrganization,
|
||||
getProjectById,
|
||||
getProjectPsiApiKey,
|
||||
setProjectPsiApiKey,
|
||||
clearProjectPsiApiKey,
|
||||
createProject,
|
||||
deleteProject,
|
||||
} as const;
|
||||
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
163
src/server/lib/audit/lighthouse.ts
Normal file
163
src/server/lib/audit/lighthouse.ts
Normal 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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -2,12 +2,27 @@
|
||||
* 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 {
|
||||
maxPages: number;
|
||||
psiStrategy: PsiStrategy;
|
||||
psiApiKey?: string;
|
||||
lighthouseStrategy: LighthouseStrategy;
|
||||
}
|
||||
|
||||
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. */
|
||||
@ -47,8 +62,8 @@ export interface PageAnalysis {
|
||||
hreflangTags: string[];
|
||||
}
|
||||
|
||||
/** PSI result for a single URL+strategy. */
|
||||
export interface PsiResult {
|
||||
/** Lighthouse result for a single URL+strategy. */
|
||||
export interface LighthouseResult {
|
||||
url: string;
|
||||
pageId: string;
|
||||
strategy: "mobile" | "desktop";
|
||||
@ -63,5 +78,35 @@ export interface PsiResult {
|
||||
errorMessage?: string | null;
|
||||
r2Key?: string | 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;
|
||||
}
|
||||
|
||||
@ -18,6 +18,9 @@ import {
|
||||
type LabsKeywordDataItem,
|
||||
type SerpLiveItem,
|
||||
} 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 {
|
||||
fetchBacklinksRowsRaw,
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
60
src/server/lib/dataforseoLighthouse.ts
Normal file
60
src/server/lib/dataforseoLighthouse.ts
Normal 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
250
src/server/lib/dataforseoLighthousePayload.test.ts
Normal file
250
src/server/lib/dataforseoLighthousePayload.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
172
src/server/lib/dataforseoLighthousePayload.ts
Normal file
172
src/server/lib/dataforseoLighthousePayload.ts
Normal 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;
|
||||
}
|
||||
123
src/server/lib/lighthousePayload.ts
Normal file
123
src/server/lib/lighthousePayload.ts
Normal 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,
|
||||
),
|
||||
};
|
||||
}
|
||||
159
src/server/lib/lighthouseStoredPayload.test.ts
Normal file
159
src/server/lib/lighthouseStoredPayload.test.ts
Normal 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}',
|
||||
);
|
||||
});
|
||||
});
|
||||
310
src/server/lib/lighthouseStoredPayload.ts
Normal file
310
src/server/lib/lighthouseStoredPayload.ts
Normal 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"]),
|
||||
};
|
||||
}
|
||||
@ -9,12 +9,14 @@ import {
|
||||
type WorkflowEvent,
|
||||
type WorkflowStep,
|
||||
} from "cloudflare:workers";
|
||||
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
||||
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
|
||||
import type { AuditConfig } from "@/server/lib/audit/types";
|
||||
import { runAuditPhases } from "@/server/workflows/siteAuditWorkflowPhases";
|
||||
|
||||
interface AuditParams {
|
||||
auditId: string;
|
||||
billingCustomer: BillingCustomerContext;
|
||||
projectId: string;
|
||||
startUrl: string;
|
||||
config: AuditConfig;
|
||||
@ -22,7 +24,8 @@ interface AuditParams {
|
||||
|
||||
export class SiteAuditWorkflow extends WorkflowEntrypoint<Env, AuditParams> {
|
||||
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(
|
||||
auditId,
|
||||
@ -41,6 +44,7 @@ export class SiteAuditWorkflow extends WorkflowEntrypoint<Env, AuditParams> {
|
||||
await runAuditPhases(step, {
|
||||
auditId,
|
||||
workflowInstanceId: event.instanceId,
|
||||
billingCustomer,
|
||||
projectId,
|
||||
startUrl,
|
||||
config,
|
||||
|
||||
@ -1,64 +1,6 @@
|
||||
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 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(
|
||||
url: string,
|
||||
|
||||
@ -1,12 +1,10 @@
|
||||
import type { WorkflowStep } from "cloudflare:workers";
|
||||
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 { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
|
||||
import { AuditProgressKV } from "@/server/lib/audit/progress-kv";
|
||||
import {
|
||||
crawlPage,
|
||||
type StepPageResult,
|
||||
} from "@/server/workflows/site-audit-workflow-helpers";
|
||||
import { crawlPage } from "@/server/workflows/site-audit-workflow-helpers";
|
||||
|
||||
const CRAWL_CONCURRENCY = 25;
|
||||
|
||||
|
||||
@ -1,19 +1,23 @@
|
||||
import type { WorkflowStep } from "cloudflare:workers";
|
||||
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
||||
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 { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
|
||||
import { AuditProgressKV } from "@/server/lib/audit/progress-kv";
|
||||
import type { AuditConfig, PsiResult } from "@/server/lib/audit/types";
|
||||
import {
|
||||
fetchPsiAndUploadToR2,
|
||||
type StepPageResult,
|
||||
} from "@/server/workflows/site-audit-workflow-helpers";
|
||||
import type {
|
||||
AuditConfig,
|
||||
LighthouseResult,
|
||||
StepPageResult,
|
||||
} from "@/server/lib/audit/types";
|
||||
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;
|
||||
failed: number;
|
||||
} {
|
||||
@ -32,6 +36,7 @@ function countPsiBatchResults(results: PsiResult[]): {
|
||||
type AuditPhasesParams = {
|
||||
auditId: string;
|
||||
workflowInstanceId: string;
|
||||
billingCustomer: BillingCustomerContext;
|
||||
projectId: string;
|
||||
startUrl: string;
|
||||
config: AuditConfig;
|
||||
@ -41,7 +46,14 @@ export async function runAuditPhases(
|
||||
step: WorkflowStep,
|
||||
params: AuditPhasesParams,
|
||||
) {
|
||||
const { auditId, workflowInstanceId, projectId, startUrl, config } = params;
|
||||
const {
|
||||
auditId,
|
||||
workflowInstanceId,
|
||||
billingCustomer,
|
||||
projectId,
|
||||
startUrl,
|
||||
config,
|
||||
} = params;
|
||||
const origin = getOrigin(startUrl);
|
||||
const maxPages = config.maxPages;
|
||||
|
||||
@ -62,15 +74,22 @@ export async function runAuditPhases(
|
||||
robots,
|
||||
sitemapUrls: discovery.sitemapUrls,
|
||||
});
|
||||
const psiResults = await runPsiPhase(step, {
|
||||
const lighthouseResults = await runLighthousePhase(step, {
|
||||
auditId,
|
||||
workflowInstanceId,
|
||||
billingCustomer,
|
||||
projectId,
|
||||
startUrl,
|
||||
config,
|
||||
allPages,
|
||||
});
|
||||
await finalizeAudit(step, auditId, workflowInstanceId, allPages, psiResults);
|
||||
await finalizeAudit(
|
||||
step,
|
||||
auditId,
|
||||
workflowInstanceId,
|
||||
allPages,
|
||||
lighthouseResults,
|
||||
);
|
||||
}
|
||||
|
||||
async function runDiscoveryPhase(
|
||||
@ -90,115 +109,134 @@ async function runDiscoveryPhase(
|
||||
});
|
||||
}
|
||||
|
||||
type PsiPhaseParams = {
|
||||
type LighthousePhaseParams = {
|
||||
auditId: string;
|
||||
workflowInstanceId: string;
|
||||
billingCustomer: BillingCustomerContext;
|
||||
projectId: string;
|
||||
startUrl: string;
|
||||
config: AuditConfig;
|
||||
allPages: StepPageResult[];
|
||||
};
|
||||
|
||||
async function runPsiPhase(
|
||||
async function runLighthousePhase(
|
||||
step: WorkflowStep,
|
||||
params: PsiPhaseParams,
|
||||
): Promise<PsiResult[]> {
|
||||
const { auditId, workflowInstanceId, projectId, startUrl, config, allPages } =
|
||||
params;
|
||||
if (config.psiStrategy === "none" || !config.psiApiKey) return [];
|
||||
params: LighthousePhaseParams,
|
||||
): Promise<LighthouseResult[]> {
|
||||
const {
|
||||
auditId,
|
||||
workflowInstanceId,
|
||||
billingCustomer,
|
||||
projectId,
|
||||
startUrl,
|
||||
config,
|
||||
allPages,
|
||||
} = params;
|
||||
if (config.lighthouseStrategy === "none") return [];
|
||||
|
||||
const psiSample = await selectPsiUrls({
|
||||
const lighthouseWork = await selectLighthousePages({
|
||||
step,
|
||||
auditId,
|
||||
workflowInstanceId,
|
||||
allPages,
|
||||
startUrl,
|
||||
strategy: config.psiStrategy,
|
||||
});
|
||||
const psiWork = psiSample.flatMap((psiUrl) => {
|
||||
const page = allPages.find((candidate) => candidate.url === psiUrl);
|
||||
if (!page) return [];
|
||||
return [{ url: psiUrl, pageId: page.id }];
|
||||
strategy: config.lighthouseStrategy,
|
||||
});
|
||||
|
||||
const psiResults: PsiResult[] = [];
|
||||
let psiCompleted = 0;
|
||||
let psiFailed = 0;
|
||||
let psiBatchIndex = 0;
|
||||
const lighthouseResults: LighthouseResult[] = [];
|
||||
let completedChecks = 0;
|
||||
let failedChecks = 0;
|
||||
let lighthouseBatchIndex = 0;
|
||||
|
||||
for (let i = 0; i < psiWork.length; i += PSI_URL_CONCURRENCY) {
|
||||
const batch = psiWork.slice(i, i + PSI_URL_CONCURRENCY);
|
||||
psiBatchIndex += 1;
|
||||
const psiBatchResults = await runPsiBatch({
|
||||
for (let i = 0; i < lighthouseWork.length; i += LIGHTHOUSE_URL_BATCH_SIZE) {
|
||||
const batch = lighthouseWork.slice(i, i + LIGHTHOUSE_URL_BATCH_SIZE);
|
||||
lighthouseBatchIndex += 1;
|
||||
const lighthouseBatchResults = await runLighthouseBatch({
|
||||
step,
|
||||
psiBatchIndex,
|
||||
lighthouseBatchIndex,
|
||||
batch,
|
||||
psiApiKey: config.psiApiKey,
|
||||
billingCustomer,
|
||||
projectId,
|
||||
auditId,
|
||||
});
|
||||
|
||||
psiResults.push(...psiBatchResults);
|
||||
const counts = countPsiBatchResults(psiBatchResults);
|
||||
psiFailed += counts.failed;
|
||||
psiCompleted += counts.completed;
|
||||
await step.do(`psi-progress-batch-${psiBatchIndex}`, async () => {
|
||||
lighthouseResults.push(...lighthouseBatchResults);
|
||||
const counts = countLighthouseBatchResults(lighthouseBatchResults);
|
||||
failedChecks += counts.failed;
|
||||
completedChecks += counts.completed;
|
||||
await step.do(
|
||||
`lighthouse-progress-batch-${lighthouseBatchIndex}`,
|
||||
async () => {
|
||||
await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, {
|
||||
psiCompleted,
|
||||
psiFailed,
|
||||
});
|
||||
lighthouseCompleted: completedChecks,
|
||||
lighthouseFailed: failedChecks,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return psiResults;
|
||||
return lighthouseResults;
|
||||
}
|
||||
|
||||
async function selectPsiUrls(params: {
|
||||
async function selectLighthousePages(params: {
|
||||
step: WorkflowStep;
|
||||
auditId: string;
|
||||
workflowInstanceId: string;
|
||||
allPages: StepPageResult[];
|
||||
startUrl: string;
|
||||
strategy: AuditConfig["psiStrategy"];
|
||||
strategy: AuditConfig["lighthouseStrategy"];
|
||||
}) {
|
||||
const { step, auditId, workflowInstanceId, allPages, startUrl, strategy } =
|
||||
params;
|
||||
return step.do("select-psi-sample", async () => {
|
||||
const pagesForSample = allPages.map((page) => ({
|
||||
id: page.id,
|
||||
url: page.url,
|
||||
statusCode: page.statusCode,
|
||||
}));
|
||||
const sample = selectPsiSample(pagesForSample, startUrl, strategy);
|
||||
return step.do("select-lighthouse-sample", async () => {
|
||||
const sample = selectLighthouseSample(allPages, startUrl, strategy);
|
||||
const selectedUrls = new Set(sample);
|
||||
|
||||
await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, {
|
||||
currentPhase: "psi",
|
||||
psiTotal: sample.length * 2,
|
||||
psiCompleted: 0,
|
||||
psiFailed: 0,
|
||||
currentPhase: "lighthouse",
|
||||
lighthouseTotal: sample.length * 2,
|
||||
lighthouseCompleted: 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;
|
||||
psiBatchIndex: number;
|
||||
lighthouseBatchIndex: number;
|
||||
batch: Array<{ url: string; pageId: string }>;
|
||||
psiApiKey: string;
|
||||
billingCustomer: BillingCustomerContext;
|
||||
projectId: string;
|
||||
auditId: string;
|
||||
}) {
|
||||
const { step, psiBatchIndex, batch, psiApiKey, projectId, auditId } = params;
|
||||
return step.do(`psi-batch-${psiBatchIndex}`, async () => {
|
||||
const {
|
||||
step,
|
||||
lighthouseBatchIndex,
|
||||
batch,
|
||||
billingCustomer,
|
||||
projectId,
|
||||
auditId,
|
||||
} = params;
|
||||
return step.do(`lighthouse-batch-${lighthouseBatchIndex}`, async () => {
|
||||
const perUrlResults = await Promise.all(
|
||||
batch.map(async ({ url, pageId }) => {
|
||||
const [mobileResult, desktopResult] = await Promise.all([
|
||||
fetchPsiAndUploadToR2(url, pageId, "mobile", psiApiKey, {
|
||||
fetchAndStoreLighthouseResult({
|
||||
url,
|
||||
pageId,
|
||||
strategy: "mobile",
|
||||
billingCustomer,
|
||||
projectId,
|
||||
auditId,
|
||||
}),
|
||||
fetchPsiAndUploadToR2(url, pageId, "desktop", psiApiKey, {
|
||||
fetchAndStoreLighthouseResult({
|
||||
url,
|
||||
pageId,
|
||||
strategy: "desktop",
|
||||
billingCustomer,
|
||||
projectId,
|
||||
auditId,
|
||||
}),
|
||||
@ -216,13 +254,17 @@ async function finalizeAudit(
|
||||
auditId: string,
|
||||
workflowInstanceId: string,
|
||||
allPages: StepPageResult[],
|
||||
psiResults: PsiResult[],
|
||||
lighthouseResults: LighthouseResult[],
|
||||
) {
|
||||
await step.do("finalize", async () => {
|
||||
await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, {
|
||||
currentPhase: "finalizing",
|
||||
});
|
||||
await AuditRepository.batchWriteResults(auditId, allPages, psiResults);
|
||||
await AuditRepository.batchWriteResults(
|
||||
auditId,
|
||||
allPages,
|
||||
lighthouseResults,
|
||||
);
|
||||
await AuditRepository.completeAudit(auditId, workflowInstanceId, {
|
||||
pagesCrawled: allPages.length,
|
||||
pagesTotal: allPages.length,
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { AuditService } from "@/server/features/audit/services/AuditService";
|
||||
import { requireProjectContext } from "@/serverFunctions/middleware";
|
||||
import {
|
||||
startAuditSchema,
|
||||
getAuditStatusSchema,
|
||||
getAuditResultsSchema,
|
||||
getAuditHistorySchema,
|
||||
deleteAuditSchema,
|
||||
getAuditHistorySchema,
|
||||
getAuditResultsSchema,
|
||||
getAuditStatusSchema,
|
||||
getCrawlProgressSchema,
|
||||
startAuditSchema,
|
||||
} from "@/types/schemas/audit";
|
||||
import { AuditService } from "@/server/features/audit/services/AuditService";
|
||||
|
||||
export const startAudit = createServerFn({ method: "POST" })
|
||||
.middleware(requireProjectContext)
|
||||
@ -16,11 +16,14 @@ export const startAudit = createServerFn({ method: "POST" })
|
||||
.handler(async ({ data, context }) => {
|
||||
return AuditService.startAudit({
|
||||
actorUserId: context.userId,
|
||||
billingCustomer: {
|
||||
organizationId: context.organizationId,
|
||||
userEmail: context.userEmail,
|
||||
},
|
||||
projectId: context.project.id,
|
||||
startUrl: data.startUrl,
|
||||
maxPages: data.maxPages,
|
||||
psiStrategy: data.psiStrategy,
|
||||
psiApiKey: data.psiApiKey,
|
||||
lighthouseStrategy: data.lighthouseStrategy,
|
||||
});
|
||||
});
|
||||
|
||||
@ -38,9 +41,7 @@ export const getAuditResults = createServerFn({ method: "POST" })
|
||||
return AuditService.getResults(data.auditId, context.project.id);
|
||||
});
|
||||
|
||||
export const getAuditHistory = createServerFn({
|
||||
method: "POST",
|
||||
})
|
||||
export const getAuditHistory = createServerFn({ method: "POST" })
|
||||
.middleware(requireProjectContext)
|
||||
.inputValidator((data: unknown) => getAuditHistorySchema.parse(data))
|
||||
.handler(async ({ context }) => {
|
||||
|
||||
88
src/serverFunctions/lighthouse.ts
Normal file
88
src/serverFunctions/lighthouse.ts
Normal 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,
|
||||
});
|
||||
});
|
||||
@ -1,9 +1,6 @@
|
||||
import { createServerFn } from "@tanstack/react-start";
|
||||
import { ProjectService } from "@/server/features/projects/services/ProjectService";
|
||||
import {
|
||||
requireAuthenticatedContext,
|
||||
requireProjectContext,
|
||||
} from "@/serverFunctions/middleware";
|
||||
import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
|
||||
import { z } from "zod";
|
||||
|
||||
export const getOrCreateDefaultProject = createServerFn({ method: "POST" })
|
||||
@ -13,13 +10,13 @@ export const getOrCreateDefaultProject = createServerFn({ method: "POST" })
|
||||
);
|
||||
|
||||
export const getProjectAccess = createServerFn({ method: "POST" })
|
||||
.middleware(requireProjectContext)
|
||||
.middleware(requireAuthenticatedContext)
|
||||
.inputValidator((data: unknown) =>
|
||||
z.object({ projectId: z.string().min(1) }).parse(data),
|
||||
)
|
||||
.handler(async ({ context }) => {
|
||||
.handler(async ({ data, context }) => {
|
||||
return ProjectService.getProjectForOrganization(
|
||||
context.organizationId,
|
||||
context.project.id,
|
||||
data.projectId,
|
||||
);
|
||||
});
|
||||
|
||||
@ -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
14
src/shared/lighthouse.ts
Normal 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];
|
||||
@ -6,11 +6,10 @@ export const startAuditSchema = z.object({
|
||||
projectId: z.string().min(1),
|
||||
startUrl: z.string().min(1, "URL is required").max(2048),
|
||||
maxPages: z.number().int().min(10).max(10_000).optional().default(50),
|
||||
psiStrategy: z
|
||||
lighthouseStrategy: z
|
||||
.enum(["auto", "all", "manual", "none"])
|
||||
.optional()
|
||||
.default("auto"),
|
||||
psiApiKey: z.string().optional(),
|
||||
});
|
||||
|
||||
export const getAuditStatusSchema = z.object({
|
||||
|
||||
22
src/types/schemas/lighthouse.ts
Normal file
22
src/types/schemas/lighthouse.ts
Normal 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"),
|
||||
});
|
||||
@ -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"),
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user