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 generated files
|
||||||
.localflare/
|
.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 Labs pricing: https://dataforseo.com/pricing/dataforseo-labs/dataforseo-google-api
|
||||||
- DataForSEO Backlinks pricing: https://dataforseo.com/pricing/backlinks/backlinks
|
- DataForSEO Backlinks pricing: https://dataforseo.com/pricing/backlinks/backlinks
|
||||||
- Google PageSpeed Insights API docs: https://developers.google.com/speed/docs/insights/v5/get-started
|
- DataForSEO Lighthouse API docs: https://docs.dataforseo.com/v3/on_page/lighthouse/overview/
|
||||||
|
|
||||||
### 1) Site audit
|
### 1) Site audit
|
||||||
|
|
||||||
|
|||||||
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,
|
"when": 1773935379368,
|
||||||
"tag": "0004_faithful_sunset_bain",
|
"tag": "0004_faithful_sunset_bain",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1773965298920,
|
||||||
|
"tag": "0005_low_red_hulk",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "6",
|
||||||
|
"when": 1774320825595,
|
||||||
|
"tag": "0006_magical_alex_wilder",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@ -38,7 +38,7 @@ export function AuditHistorySection({
|
|||||||
<th>URL</th>
|
<th>URL</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th>Pages</th>
|
<th>Pages</th>
|
||||||
<th>PSI</th>
|
<th>Lighthouse</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@ -54,7 +54,7 @@ export function AuditHistorySection({
|
|||||||
</td>
|
</td>
|
||||||
<td>{audit.pagesTotal || audit.pagesCrawled}</td>
|
<td>{audit.pagesTotal || audit.pagesCrawled}</td>
|
||||||
<td>
|
<td>
|
||||||
{audit.ranPsi ? (
|
{audit.ranLighthouse ? (
|
||||||
<span className="badge badge-ghost badge-xs">Yes</span>
|
<span className="badge badge-ghost badge-xs">Yes</span>
|
||||||
) : null}
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@ -1,48 +1,33 @@
|
|||||||
import type { FormEvent } from "react";
|
import type { FormEvent } from "react";
|
||||||
import { Loader2, Settings } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
MAX_PAGES_LIMIT,
|
MAX_PAGES_LIMIT,
|
||||||
MIN_PAGES,
|
MIN_PAGES,
|
||||||
type LaunchFormApi,
|
type LaunchFormApi,
|
||||||
type LaunchState,
|
type LaunchState,
|
||||||
type SettingsFormApi,
|
|
||||||
} from "@/client/features/audit/launch/types";
|
} from "@/client/features/audit/launch/types";
|
||||||
|
|
||||||
export function LaunchFormCard({
|
export function LaunchFormCard({
|
||||||
launchForm,
|
launchForm,
|
||||||
settingsForm,
|
|
||||||
state,
|
state,
|
||||||
setState,
|
setState,
|
||||||
isPending,
|
isPending,
|
||||||
onSubmit,
|
onSubmit,
|
||||||
onOpenSettings,
|
onRunLighthouseToggle,
|
||||||
onRunPsiToggle,
|
|
||||||
commitMaxPagesInput,
|
commitMaxPagesInput,
|
||||||
}: {
|
}: {
|
||||||
launchForm: LaunchFormApi;
|
launchForm: LaunchFormApi;
|
||||||
settingsForm: SettingsFormApi;
|
|
||||||
state: LaunchState;
|
state: LaunchState;
|
||||||
setState: React.Dispatch<React.SetStateAction<LaunchState>>;
|
setState: React.Dispatch<React.SetStateAction<LaunchState>>;
|
||||||
isPending: boolean;
|
isPending: boolean;
|
||||||
onSubmit: (event: FormEvent) => void;
|
onSubmit: (event: FormEvent) => void;
|
||||||
onOpenSettings: () => void;
|
onRunLighthouseToggle: (checked: boolean) => void;
|
||||||
onRunPsiToggle: (checked: boolean) => void;
|
|
||||||
commitMaxPagesInput: () => number;
|
commitMaxPagesInput: () => number;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="card bg-base-100 border border-base-300">
|
<div className="card bg-base-100 border border-base-300">
|
||||||
<div className="card-body gap-4">
|
<div className="card-body gap-4">
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<h2 className="card-title text-base">Start New Audit</h2>
|
<h2 className="card-title text-base">Start New Audit</h2>
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-ghost btn-sm"
|
|
||||||
onClick={onOpenSettings}
|
|
||||||
>
|
|
||||||
<Settings className="size-4" />
|
|
||||||
Settings
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form
|
<form
|
||||||
className="grid grid-cols-1 gap-3 lg:grid-cols-12 lg:items-center"
|
className="grid grid-cols-1 gap-3 lg:grid-cols-12 lg:items-center"
|
||||||
@ -85,10 +70,9 @@ export function LaunchFormCard({
|
|||||||
launchForm={launchForm}
|
launchForm={launchForm}
|
||||||
commitMaxPagesInput={commitMaxPagesInput}
|
commitMaxPagesInput={commitMaxPagesInput}
|
||||||
/>
|
/>
|
||||||
<PsiOptions
|
<LighthouseOptions
|
||||||
launchForm={launchForm}
|
launchForm={launchForm}
|
||||||
settingsForm={settingsForm}
|
onRunLighthouseToggle={onRunLighthouseToggle}
|
||||||
onRunPsiToggle={onRunPsiToggle}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@ -138,42 +122,42 @@ function LaunchOptions({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function PsiOptions({
|
function LighthouseOptions({
|
||||||
launchForm,
|
launchForm,
|
||||||
settingsForm,
|
onRunLighthouseToggle,
|
||||||
onRunPsiToggle,
|
|
||||||
}: {
|
}: {
|
||||||
launchForm: LaunchFormApi;
|
launchForm: LaunchFormApi;
|
||||||
settingsForm: SettingsFormApi;
|
onRunLighthouseToggle: (checked: boolean) => void;
|
||||||
onRunPsiToggle: (checked: boolean) => void;
|
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-base-300 bg-base-200/20 p-3 space-y-2">
|
<div className="rounded-lg border border-base-300 bg-base-200/20 p-3 space-y-2">
|
||||||
<label className="label cursor-pointer justify-start gap-2 p-0">
|
<label className="label cursor-pointer justify-start gap-2 p-0">
|
||||||
<launchForm.Field name="runPsi">
|
<launchForm.Field name="runLighthouse">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
className="toggle toggle-sm toggle-primary"
|
className="toggle toggle-sm toggle-primary"
|
||||||
checked={Boolean(field.state.value)}
|
checked={Boolean(field.state.value)}
|
||||||
onChange={(event) => onRunPsiToggle(event.target.checked)}
|
onChange={(event) => onRunLighthouseToggle(event.target.checked)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</launchForm.Field>
|
</launchForm.Field>
|
||||||
<span
|
<span
|
||||||
className="text-sm font-medium text-base-content/80"
|
className="text-sm font-medium text-base-content/80"
|
||||||
title="Run Google PageSpeed Insights checks during this audit"
|
title="Run Lighthouse checks through DataForSEO during this audit"
|
||||||
>
|
>
|
||||||
Include PSI checks
|
Include Lighthouse checks
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<launchForm.Subscribe selector={(snapshot) => snapshot.values.runPsi}>
|
<launchForm.Subscribe
|
||||||
{(runPsi) =>
|
selector={(snapshot) => snapshot.values.runLighthouse}
|
||||||
runPsi ? (
|
>
|
||||||
|
{(runLighthouse) =>
|
||||||
|
runLighthouse ? (
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
<span className="text-xs text-base-content/60">PSI mode</span>
|
<span className="text-xs text-base-content/60">Audit scope</span>
|
||||||
<launchForm.Field name="psiMode">
|
<launchForm.Field name="lighthouseMode">
|
||||||
{(field) => (
|
{(field) => (
|
||||||
<select
|
<select
|
||||||
className="select select-bordered select-xs"
|
className="select select-bordered select-xs"
|
||||||
@ -189,17 +173,9 @@ function PsiOptions({
|
|||||||
</select>
|
</select>
|
||||||
)}
|
)}
|
||||||
</launchForm.Field>
|
</launchForm.Field>
|
||||||
<settingsForm.Subscribe
|
<span className="text-xs text-base-content/50">
|
||||||
selector={(snapshot) => snapshot.values.psiApiKey}
|
Powered by DataForSEO Lighthouse
|
||||||
>
|
|
||||||
{(psiApiKey) => (
|
|
||||||
<span
|
|
||||||
className={`text-xs ${psiApiKey.trim() ? "text-success/80" : "text-warning"}`}
|
|
||||||
>
|
|
||||||
{psiApiKey.trim() ? "PSI key saved" : "PSI key required"}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
|
||||||
</settingsForm.Subscribe>
|
|
||||||
</div>
|
</div>
|
||||||
) : null
|
) : null
|
||||||
}
|
}
|
||||||
@ -214,11 +190,6 @@ function LaunchErrors({ state }: { state: LaunchState }) {
|
|||||||
{state.urlError ? (
|
{state.urlError ? (
|
||||||
<p className="text-sm text-error">{state.urlError}</p>
|
<p className="text-sm text-error">{state.urlError}</p>
|
||||||
) : null}
|
) : null}
|
||||||
{state.psiRequirementError ? (
|
|
||||||
<div className="alert alert-warning py-2">
|
|
||||||
<span className="text-sm">{state.psiRequirementError}</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{state.startError ? (
|
{state.startError ? (
|
||||||
<div className="alert alert-error py-2">
|
<div className="alert alert-error py-2">
|
||||||
<span className="text-sm">{state.startError}</span>
|
<span className="text-sm">{state.startError}</span>
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { AuditHistorySection } from "@/client/features/audit/launch/AuditHistorySection";
|
import { AuditHistorySection } from "@/client/features/audit/launch/AuditHistorySection";
|
||||||
import { LaunchFormCard } from "@/client/features/audit/launch/LaunchFormCard";
|
import { LaunchFormCard } from "@/client/features/audit/launch/LaunchFormCard";
|
||||||
import { SettingsModal } from "@/client/features/audit/launch/SettingsModal";
|
|
||||||
import { useLaunchController } from "@/client/features/audit/launch/useLaunchController";
|
import { useLaunchController } from "@/client/features/audit/launch/useLaunchController";
|
||||||
|
|
||||||
export function LaunchView({
|
export function LaunchView({
|
||||||
@ -19,26 +18,14 @@ export function LaunchView({
|
|||||||
|
|
||||||
<LaunchFormCard
|
<LaunchFormCard
|
||||||
launchForm={controller.launchForm}
|
launchForm={controller.launchForm}
|
||||||
settingsForm={controller.settingsForm}
|
|
||||||
state={controller.state}
|
state={controller.state}
|
||||||
setState={controller.setState}
|
setState={controller.setState}
|
||||||
isPending={controller.startMutation.isPending}
|
isPending={controller.startMutation.isPending}
|
||||||
onSubmit={controller.handleSubmit}
|
onSubmit={controller.handleSubmit}
|
||||||
onOpenSettings={controller.openSettings}
|
onRunLighthouseToggle={controller.onRunLighthouseToggle}
|
||||||
onRunPsiToggle={controller.onRunPsiToggle}
|
|
||||||
commitMaxPagesInput={controller.commitMaxPagesInput}
|
commitMaxPagesInput={controller.commitMaxPagesInput}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{controller.state.isSettingsOpen && (
|
|
||||||
<SettingsModal
|
|
||||||
settingsForm={controller.settingsForm}
|
|
||||||
state={controller.state}
|
|
||||||
setState={controller.setState}
|
|
||||||
onClear={controller.clearPsiKey}
|
|
||||||
onSave={controller.saveSettings}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<AuditHistorySection
|
<AuditHistorySection
|
||||||
history={controller.historyQuery.data ?? []}
|
history={controller.historyQuery.data ?? []}
|
||||||
isLoading={controller.historyQuery.isLoading}
|
isLoading={controller.historyQuery.isLoading}
|
||||||
|
|||||||
@ -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";
|
import { useForm } from "@tanstack/react-form";
|
||||||
|
|
||||||
export type LaunchState = {
|
export type LaunchState = {
|
||||||
isSettingsOpen: boolean;
|
|
||||||
showPsiKey: boolean;
|
|
||||||
urlError: string | null;
|
urlError: string | null;
|
||||||
psiRequirementError: string | null;
|
|
||||||
startError: string | null;
|
startError: string | null;
|
||||||
settingsError: string | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const MIN_PAGES = 10;
|
export const MIN_PAGES = 10;
|
||||||
@ -17,15 +13,10 @@ export function useLaunchForm() {
|
|||||||
defaultValues: {
|
defaultValues: {
|
||||||
url: "",
|
url: "",
|
||||||
maxPagesInput: "50",
|
maxPagesInput: "50",
|
||||||
runPsi: false,
|
runLighthouse: false,
|
||||||
psiMode: "auto" as "auto" | "all",
|
lighthouseMode: "auto" as "auto" | "all",
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSettingsForm() {
|
|
||||||
return useForm({ defaultValues: { psiApiKey: "" } });
|
|
||||||
}
|
|
||||||
|
|
||||||
export type LaunchFormApi = ReturnType<typeof useLaunchForm>;
|
export type LaunchFormApi = ReturnType<typeof useLaunchForm>;
|
||||||
export type SettingsFormApi = ReturnType<typeof useSettingsForm>;
|
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
@ -6,16 +6,10 @@ import {
|
|||||||
getAuditHistory,
|
getAuditHistory,
|
||||||
startAudit,
|
startAudit,
|
||||||
} from "@/serverFunctions/audit";
|
} from "@/serverFunctions/audit";
|
||||||
import {
|
|
||||||
clearProjectPsiApiKey,
|
|
||||||
getProjectPsiApiKey,
|
|
||||||
saveProjectPsiApiKey,
|
|
||||||
} from "@/serverFunctions/psi";
|
|
||||||
import {
|
import {
|
||||||
MAX_PAGES_LIMIT,
|
MAX_PAGES_LIMIT,
|
||||||
MIN_PAGES,
|
MIN_PAGES,
|
||||||
useLaunchForm,
|
useLaunchForm,
|
||||||
useSettingsForm,
|
|
||||||
type LaunchState,
|
type LaunchState,
|
||||||
} from "@/client/features/audit/launch/types";
|
} from "@/client/features/audit/launch/types";
|
||||||
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
import { getStandardErrorMessage } from "@/client/lib/error-messages";
|
||||||
@ -28,34 +22,20 @@ export function useLaunchController({
|
|||||||
onAuditStarted: (auditId: string) => void;
|
onAuditStarted: (auditId: string) => void;
|
||||||
}) {
|
}) {
|
||||||
const launchForm = useLaunchForm();
|
const launchForm = useLaunchForm();
|
||||||
const settingsForm = useSettingsForm();
|
|
||||||
const [state, setState] = useState<LaunchState>({
|
const [state, setState] = useState<LaunchState>({
|
||||||
isSettingsOpen: false,
|
|
||||||
showPsiKey: false,
|
|
||||||
urlError: null,
|
urlError: null,
|
||||||
psiRequirementError: null,
|
|
||||||
startError: null,
|
startError: null,
|
||||||
settingsError: null,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const historyQuery = useQuery({
|
const historyQuery = useQuery({
|
||||||
queryKey: ["audit-history", projectId],
|
queryKey: ["audit-history", projectId],
|
||||||
queryFn: () => getAuditHistory({ data: { projectId } }),
|
queryFn: () => getAuditHistory({ data: { projectId } }),
|
||||||
});
|
});
|
||||||
const keyQuery = useQuery({
|
const { startMutation, deleteMutation } = useLaunchMutations({
|
||||||
queryKey: ["projectPsiApiKey", projectId],
|
|
||||||
queryFn: () => getProjectPsiApiKey({ data: { projectId } }),
|
|
||||||
});
|
|
||||||
const { startMutation, deleteMutation, saveKeyMutation, clearKeyMutation } =
|
|
||||||
useLaunchMutations({
|
|
||||||
projectId,
|
projectId,
|
||||||
historyRefetch: historyQuery.refetch,
|
historyRefetch: historyQuery.refetch,
|
||||||
keyRefetch: keyQuery.refetch,
|
|
||||||
clearPsiApiKeyField: () => settingsForm.setFieldValue("psiApiKey", ""),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
useSyncPsiKeyField(keyQuery.data?.apiKey, settingsForm);
|
|
||||||
|
|
||||||
const applyMaxPages = (value: number) => {
|
const applyMaxPages = (value: number) => {
|
||||||
const safeValue = Number.isFinite(value)
|
const safeValue = Number.isFinite(value)
|
||||||
? Math.max(MIN_PAGES, Math.min(MAX_PAGES_LIMIT, Math.round(value)))
|
? Math.max(MIN_PAGES, Math.min(MAX_PAGES_LIMIT, Math.round(value)))
|
||||||
@ -72,20 +52,13 @@ export function useLaunchController({
|
|||||||
|
|
||||||
const handleStart = () => {
|
const handleStart = () => {
|
||||||
const launchValues = launchForm.state.values;
|
const launchValues = launchForm.state.values;
|
||||||
const settingsValues = settingsForm.state.values;
|
|
||||||
const effectiveMaxPages = commitMaxPagesInput();
|
const effectiveMaxPages = commitMaxPagesInput();
|
||||||
setState((prev) => ({ ...prev, startError: null }));
|
setState((prev) => ({ ...prev, startError: null }));
|
||||||
|
|
||||||
if (!launchValues.url.trim())
|
if (!launchValues.url.trim()) {
|
||||||
return setState((prev) => ({ ...prev, urlError: "Please enter a URL." }));
|
return setState((prev) => ({ ...prev, urlError: "Please enter a URL." }));
|
||||||
if (launchValues.runPsi && !settingsValues.psiApiKey.trim()) {
|
|
||||||
return setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
psiRequirementError:
|
|
||||||
"Set a Google PageSpeed Insights API key before running PSI checks.",
|
|
||||||
isSettingsOpen: true,
|
|
||||||
}));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (effectiveMaxPages > 500) {
|
if (effectiveMaxPages > 500) {
|
||||||
const confirmed = window.confirm(
|
const confirmed = window.confirm(
|
||||||
`You are about to crawl ${effectiveMaxPages.toLocaleString()} pages. This is okay, but it may take a while. Continue?`,
|
`You are about to crawl ${effectiveMaxPages.toLocaleString()} pages. This is okay, but it may take a while. Continue?`,
|
||||||
@ -98,19 +71,13 @@ export function useLaunchController({
|
|||||||
projectId,
|
projectId,
|
||||||
startUrl: launchValues.url,
|
startUrl: launchValues.url,
|
||||||
maxPages: effectiveMaxPages,
|
maxPages: effectiveMaxPages,
|
||||||
psiStrategy: launchValues.runPsi ? launchValues.psiMode : "none",
|
lighthouseStrategy: launchValues.runLighthouse
|
||||||
psiApiKey: launchValues.runPsi
|
? launchValues.lighthouseMode
|
||||||
? settingsValues.psiApiKey || undefined
|
: "none",
|
||||||
: undefined,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
onSuccess: (result) => {
|
onSuccess: (result) => {
|
||||||
setState((prev) => ({
|
setState({ urlError: null, startError: null });
|
||||||
...prev,
|
|
||||||
urlError: null,
|
|
||||||
psiRequirementError: null,
|
|
||||||
startError: null,
|
|
||||||
}));
|
|
||||||
toast.success("Audit started!");
|
toast.success("Audit started!");
|
||||||
onAuditStarted(result.auditId);
|
onAuditStarted(result.auditId);
|
||||||
},
|
},
|
||||||
@ -126,7 +93,6 @@ export function useLaunchController({
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
launchForm,
|
launchForm,
|
||||||
settingsForm,
|
|
||||||
state,
|
state,
|
||||||
setState,
|
setState,
|
||||||
historyQuery,
|
historyQuery,
|
||||||
@ -136,45 +102,25 @@ export function useLaunchController({
|
|||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
handleStart();
|
handleStart();
|
||||||
},
|
},
|
||||||
openSettings: () => setState((prev) => ({ ...prev, isSettingsOpen: true })),
|
onRunLighthouseToggle: (checked: boolean) =>
|
||||||
onRunPsiToggle: (checked: boolean) =>
|
handleRunLighthouseToggle(checked, launchForm),
|
||||||
handleRunPsiToggle(checked, launchForm, settingsForm, setState),
|
|
||||||
saveSettings: () =>
|
|
||||||
handleSaveSettings(settingsForm, setState, saveKeyMutation.mutate),
|
|
||||||
clearPsiKey: () => clearKeyMutation.mutate(),
|
|
||||||
deleteAudit: (auditId: string) => deleteMutation.mutate(auditId),
|
deleteAudit: (auditId: string) => deleteMutation.mutate(auditId),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function useSyncPsiKeyField(
|
|
||||||
apiKey: string | null | undefined,
|
|
||||||
settingsForm: ReturnType<typeof useSettingsForm>,
|
|
||||||
) {
|
|
||||||
useEffect(() => {
|
|
||||||
if (apiKey) {
|
|
||||||
settingsForm.setFieldValue("psiApiKey", apiKey);
|
|
||||||
}
|
|
||||||
}, [apiKey, settingsForm]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function useLaunchMutations({
|
function useLaunchMutations({
|
||||||
projectId,
|
projectId,
|
||||||
historyRefetch,
|
historyRefetch,
|
||||||
keyRefetch,
|
|
||||||
clearPsiApiKeyField,
|
|
||||||
}: {
|
}: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
historyRefetch: () => Promise<unknown>;
|
historyRefetch: () => Promise<unknown>;
|
||||||
keyRefetch: () => Promise<unknown>;
|
|
||||||
clearPsiApiKeyField: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
const startMutation = useMutation({
|
const startMutation = useMutation({
|
||||||
mutationFn: (data: {
|
mutationFn: (data: {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
startUrl: string;
|
startUrl: string;
|
||||||
maxPages: number;
|
maxPages: number;
|
||||||
psiStrategy: "auto" | "all" | "none";
|
lighthouseStrategy: "auto" | "all" | "none";
|
||||||
psiApiKey?: string;
|
|
||||||
}) => startAudit({ data }),
|
}) => startAudit({ data }),
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -187,67 +133,12 @@ function useLaunchMutations({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const saveKeyMutation = useMutation({
|
return { startMutation, deleteMutation };
|
||||||
mutationFn: (apiKey: string) =>
|
|
||||||
saveProjectPsiApiKey({ data: { projectId, apiKey } }),
|
|
||||||
onSuccess: async () => {
|
|
||||||
toast.success("PSI API key saved for this project");
|
|
||||||
await keyRefetch();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const clearKeyMutation = useMutation({
|
|
||||||
mutationFn: () => clearProjectPsiApiKey({ data: { projectId } }),
|
|
||||||
onSuccess: async () => {
|
|
||||||
clearPsiApiKeyField();
|
|
||||||
toast.success("PSI API key cleared");
|
|
||||||
await keyRefetch();
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
return { startMutation, deleteMutation, saveKeyMutation, clearKeyMutation };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleRunPsiToggle(
|
function handleRunLighthouseToggle(
|
||||||
checked: boolean,
|
checked: boolean,
|
||||||
launchForm: ReturnType<typeof useLaunchForm>,
|
launchForm: ReturnType<typeof useLaunchForm>,
|
||||||
settingsForm: ReturnType<typeof useSettingsForm>,
|
|
||||||
setState: React.Dispatch<React.SetStateAction<LaunchState>>,
|
|
||||||
) {
|
) {
|
||||||
if (!checked) {
|
launchForm.setFieldValue("runLighthouse", checked);
|
||||||
setState((prev) => ({ ...prev, psiRequirementError: null }));
|
|
||||||
launchForm.setFieldValue("runPsi", false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!settingsForm.state.values.psiApiKey.trim()) {
|
|
||||||
setState((prev) => ({ ...prev, isSettingsOpen: true }));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
launchForm.setFieldValue("runPsi", true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleSaveSettings(
|
|
||||||
settingsForm: ReturnType<typeof useSettingsForm>,
|
|
||||||
setState: React.Dispatch<React.SetStateAction<LaunchState>>,
|
|
||||||
save: (apiKey: string) => void,
|
|
||||||
) {
|
|
||||||
const trimmed = settingsForm.state.values.psiApiKey.trim();
|
|
||||||
if (!trimmed) {
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
settingsError: "Please enter an API key.",
|
|
||||||
}));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setState((prev) => ({
|
|
||||||
...prev,
|
|
||||||
settingsError: null,
|
|
||||||
psiRequirementError: null,
|
|
||||||
showPsiKey: false,
|
|
||||||
isSettingsOpen: false,
|
|
||||||
}));
|
|
||||||
save(trimmed);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,10 +2,35 @@ import { ChevronDown, Download, ExternalLink } from "lucide-react";
|
|||||||
import {
|
import {
|
||||||
extractPathname,
|
extractPathname,
|
||||||
HttpStatusBadge,
|
HttpStatusBadge,
|
||||||
PsiScoreBadge,
|
LighthouseScoreBadge,
|
||||||
} from "@/client/features/audit/shared";
|
} from "@/client/features/audit/shared";
|
||||||
import type { AuditResultsData } from "@/client/features/audit/results/types";
|
import type { AuditResultsData } from "@/client/features/audit/results/types";
|
||||||
|
|
||||||
|
type LighthouseFailureFields = {
|
||||||
|
errorMessage: string | null;
|
||||||
|
performanceScore: number | null;
|
||||||
|
accessibilityScore: number | null;
|
||||||
|
bestPracticesScore: number | null;
|
||||||
|
seoScore: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function hasMissingLighthouseScores(row: LighthouseFailureFields) {
|
||||||
|
return (
|
||||||
|
row.performanceScore == null &&
|
||||||
|
row.accessibilityScore == null &&
|
||||||
|
row.bestPracticesScore == null &&
|
||||||
|
row.seoScore == null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLighthouseFailure(row: LighthouseFailureFields) {
|
||||||
|
return !!row.errorMessage || hasMissingLighthouseScores(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLighthouseFailureMessage(row: LighthouseFailureFields) {
|
||||||
|
return row.errorMessage ?? "Lighthouse returned no category scores";
|
||||||
|
}
|
||||||
|
|
||||||
export function PagesTable({ pages }: { pages: AuditResultsData["pages"] }) {
|
export function PagesTable({ pages }: { pages: AuditResultsData["pages"] }) {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@ -22,7 +47,7 @@ export function PagesTable({ pages }: { pages: AuditResultsData["pages"] }) {
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{pages.map((page) => (
|
{pages.map((page: AuditResultsData["pages"][number]) => (
|
||||||
<tr key={page.id}>
|
<tr key={page.id}>
|
||||||
<td className="max-w-[200px] truncate">
|
<td className="max-w-[200px] truncate">
|
||||||
<a
|
<a
|
||||||
@ -66,12 +91,14 @@ export function PagesTable({ pages }: { pages: AuditResultsData["pages"] }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PerformanceTable({
|
export function PerformanceTable({
|
||||||
|
auditId,
|
||||||
projectId,
|
projectId,
|
||||||
psi,
|
lighthouse,
|
||||||
pages,
|
pages,
|
||||||
}: {
|
}: {
|
||||||
|
auditId: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
psi: AuditResultsData["psi"];
|
lighthouse: AuditResultsData["lighthouse"];
|
||||||
pages: AuditResultsData["pages"];
|
pages: AuditResultsData["pages"];
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@ -93,12 +120,16 @@ export function PerformanceTable({
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{psi.map((result) => (
|
{lighthouse.map((result: AuditResultsData["lighthouse"][number]) => (
|
||||||
<PerformanceRow
|
<PerformanceRow
|
||||||
key={result.id}
|
key={result.id}
|
||||||
|
auditId={auditId}
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
result={result}
|
result={result}
|
||||||
page={pages.find((candidate) => candidate.id === result.pageId)}
|
page={pages.find(
|
||||||
|
(candidate: AuditResultsData["pages"][number]) =>
|
||||||
|
candidate.id === result.pageId,
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@ -108,15 +139,18 @@ export function PerformanceTable({
|
|||||||
}
|
}
|
||||||
|
|
||||||
function PerformanceRow({
|
function PerformanceRow({
|
||||||
|
auditId,
|
||||||
projectId,
|
projectId,
|
||||||
result,
|
result,
|
||||||
page,
|
page,
|
||||||
}: {
|
}: {
|
||||||
|
auditId: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
result: AuditResultsData["psi"][number];
|
result: AuditResultsData["lighthouse"][number];
|
||||||
page: AuditResultsData["pages"][number] | undefined;
|
page: AuditResultsData["pages"][number] | undefined;
|
||||||
}) {
|
}) {
|
||||||
const isFailed = !!result.errorMessage;
|
const isFailed = isLighthouseFailure(result);
|
||||||
|
const failureMessage = getLighthouseFailureMessage(result);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr>
|
<tr>
|
||||||
@ -128,7 +162,7 @@ function PerformanceRow({
|
|||||||
{isFailed ? (
|
{isFailed ? (
|
||||||
<span
|
<span
|
||||||
className="badge badge-error badge-outline text-xs"
|
className="badge badge-error badge-outline text-xs"
|
||||||
title={result.errorMessage ?? "PSI check failed"}
|
title={failureMessage}
|
||||||
>
|
>
|
||||||
failed
|
failed
|
||||||
</span>
|
</span>
|
||||||
@ -137,13 +171,13 @@ function PerformanceRow({
|
|||||||
)}
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<PsiScoreBadge score={result.performanceScore} />
|
<LighthouseScoreBadge score={result.performanceScore} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<PsiScoreBadge score={result.accessibilityScore} />
|
<LighthouseScoreBadge score={result.accessibilityScore} />
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<PsiScoreBadge score={result.seoScore} />
|
<LighthouseScoreBadge score={result.seoScore} />
|
||||||
</td>
|
</td>
|
||||||
<td className="text-xs">
|
<td className="text-xs">
|
||||||
{result.lcpMs ? `${(result.lcpMs / 1000).toFixed(1)}s` : "-"}
|
{result.lcpMs ? `${(result.lcpMs / 1000).toFixed(1)}s` : "-"}
|
||||||
@ -158,10 +192,10 @@ function PerformanceRow({
|
|||||||
{result.ttfbMs ? `${Math.round(result.ttfbMs)}ms` : "-"}
|
{result.ttfbMs ? `${Math.round(result.ttfbMs)}ms` : "-"}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
{result.r2Key ? (
|
{result.r2Key && !isFailed ? (
|
||||||
<a
|
<a
|
||||||
className="btn btn-primary btn-xs"
|
className="btn btn-primary btn-xs"
|
||||||
href={`/p/${projectId}/audit/issues/${result.id}?category=performance`}
|
href={`/p/${projectId}/audit/issues/${result.id}?auditId=${auditId}&category=performance`}
|
||||||
>
|
>
|
||||||
View issues
|
View issues
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import {
|
|||||||
import type { AuditResultsData } from "@/client/features/audit/results/types";
|
import type { AuditResultsData } from "@/client/features/audit/results/types";
|
||||||
import {
|
import {
|
||||||
ExportDropdown,
|
ExportDropdown,
|
||||||
|
isLighthouseFailure,
|
||||||
PagesTable,
|
PagesTable,
|
||||||
PerformanceTable,
|
PerformanceTable,
|
||||||
} from "@/client/features/audit/results/ResultsTables";
|
} from "@/client/features/audit/results/ResultsTables";
|
||||||
@ -24,32 +25,32 @@ export function ResultsView({
|
|||||||
tab: string;
|
tab: string;
|
||||||
setSearchParams: SearchSetter;
|
setSearchParams: SearchSetter;
|
||||||
}) {
|
}) {
|
||||||
const { audit, pages, psi } = data;
|
const { audit, pages, lighthouse } = data;
|
||||||
const hasPerformanceTab = psi.length > 0;
|
const hasPerformanceTab = lighthouse.length > 0;
|
||||||
const activeTab = hasPerformanceTab ? tab : "pages";
|
const activeTab = hasPerformanceTab ? tab : "pages";
|
||||||
const stats = useResultStats(pages, psi);
|
const stats = useResultStats(pages, lighthouse);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<StatsGrid
|
<StatsGrid
|
||||||
pagesCrawled={audit.pagesCrawled}
|
pagesCrawled={audit.pagesCrawled}
|
||||||
totalPages={pages.length}
|
totalPages={pages.length}
|
||||||
totalPsi={psi.length}
|
totalLighthouse={lighthouse.length}
|
||||||
averageResponseMs={stats.averageResponseMs}
|
averageResponseMs={stats.averageResponseMs}
|
||||||
psiSummary={stats.psiSummary}
|
lighthouseSummary={stats.lighthouseSummary}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="card bg-base-100 border border-base-300">
|
<div className="card bg-base-100 border border-base-300">
|
||||||
<div className="card-body gap-3">
|
<div className="card-body gap-3">
|
||||||
<ResultsHeader
|
<ResultsHeader
|
||||||
pageCount={pages.length}
|
pageCount={pages.length}
|
||||||
psiCount={psi.length}
|
lighthouseCount={lighthouse.length}
|
||||||
hasPerformanceTab={hasPerformanceTab}
|
hasPerformanceTab={hasPerformanceTab}
|
||||||
activeTab={activeTab}
|
activeTab={activeTab}
|
||||||
setSearchParams={setSearchParams}
|
setSearchParams={setSearchParams}
|
||||||
onExport={(format) => {
|
onExport={(format) => {
|
||||||
if (activeTab === "performance") {
|
if (activeTab === "performance") {
|
||||||
exportPerformance(psi, pages, format);
|
exportPerformance(lighthouse, pages, format);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
exportPages(pages, format);
|
exportPages(pages, format);
|
||||||
@ -57,8 +58,13 @@ export function ResultsView({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{activeTab === "pages" && <PagesTable pages={pages} />}
|
{activeTab === "pages" && <PagesTable pages={pages} />}
|
||||||
{activeTab === "performance" && psi.length > 0 && (
|
{activeTab === "performance" && lighthouse.length > 0 && (
|
||||||
<PerformanceTable projectId={projectId} psi={psi} pages={pages} />
|
<PerformanceTable
|
||||||
|
auditId={audit.id}
|
||||||
|
projectId={projectId}
|
||||||
|
lighthouse={lighthouse}
|
||||||
|
pages={pages}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -68,28 +74,34 @@ export function ResultsView({
|
|||||||
|
|
||||||
function useResultStats(
|
function useResultStats(
|
||||||
pages: AuditResultsData["pages"],
|
pages: AuditResultsData["pages"],
|
||||||
psi: AuditResultsData["psi"],
|
lighthouse: AuditResultsData["lighthouse"],
|
||||||
) {
|
) {
|
||||||
const averageResponseMs = useMemo(() => {
|
const averageResponseMs = useMemo(() => {
|
||||||
if (pages.length === 0) return 0;
|
if (pages.length === 0) return 0;
|
||||||
const total = pages.reduce(
|
const total = pages.reduce(
|
||||||
(sum, page) => sum + (page.responseTimeMs ?? 0),
|
(sum: number, page: AuditResultsData["pages"][number]) =>
|
||||||
|
sum + (page.responseTimeMs ?? 0),
|
||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
return Math.round(total / pages.length);
|
return Math.round(total / pages.length);
|
||||||
}, [pages]);
|
}, [pages]);
|
||||||
|
|
||||||
const psiSummary = useMemo(() => {
|
const lighthouseSummary = useMemo(() => {
|
||||||
const failed = psi.filter((row) => !!row.errorMessage).length;
|
const failed = lighthouse.filter(
|
||||||
const successful = psi.filter((row) => !row.errorMessage);
|
(row: AuditResultsData["lighthouse"][number]) => isLighthouseFailure(row),
|
||||||
|
).length;
|
||||||
|
const successful = lighthouse.filter(
|
||||||
|
(row: AuditResultsData["lighthouse"][number]) =>
|
||||||
|
!isLighthouseFailure(row),
|
||||||
|
);
|
||||||
const averageScore = (
|
const averageScore = (
|
||||||
key: "performanceScore" | "seoScore" | "accessibilityScore",
|
key: "performanceScore" | "seoScore" | "accessibilityScore",
|
||||||
) => {
|
) => {
|
||||||
const values = successful
|
const values = successful
|
||||||
.map((row) => row[key])
|
.map((row: AuditResultsData["lighthouse"][number]) => row[key])
|
||||||
.filter((value): value is number => value != null);
|
.filter((value: number | null): value is number => value != null);
|
||||||
if (values.length === 0) return null;
|
if (values.length === 0) return null;
|
||||||
const total = values.reduce((sum, value) => sum + value, 0);
|
const total = values.reduce((sum: number, value) => sum + value, 0);
|
||||||
return Math.round(total / values.length);
|
return Math.round(total / values.length);
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -99,21 +111,21 @@ function useResultStats(
|
|||||||
avgSeo: averageScore("seoScore"),
|
avgSeo: averageScore("seoScore"),
|
||||||
avgAccessibility: averageScore("accessibilityScore"),
|
avgAccessibility: averageScore("accessibilityScore"),
|
||||||
};
|
};
|
||||||
}, [psi]);
|
}, [lighthouse]);
|
||||||
|
|
||||||
return { averageResponseMs, psiSummary };
|
return { averageResponseMs, lighthouseSummary };
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResultsHeader({
|
function ResultsHeader({
|
||||||
pageCount,
|
pageCount,
|
||||||
psiCount,
|
lighthouseCount,
|
||||||
hasPerformanceTab,
|
hasPerformanceTab,
|
||||||
activeTab,
|
activeTab,
|
||||||
setSearchParams,
|
setSearchParams,
|
||||||
onExport,
|
onExport,
|
||||||
}: {
|
}: {
|
||||||
pageCount: number;
|
pageCount: number;
|
||||||
psiCount: number;
|
lighthouseCount: number;
|
||||||
hasPerformanceTab: boolean;
|
hasPerformanceTab: boolean;
|
||||||
activeTab: string;
|
activeTab: string;
|
||||||
setSearchParams: SearchSetter;
|
setSearchParams: SearchSetter;
|
||||||
@ -135,7 +147,7 @@ function ResultsHeader({
|
|||||||
className={`tab ${activeTab === "performance" ? "tab-active" : ""}`}
|
className={`tab ${activeTab === "performance" ? "tab-active" : ""}`}
|
||||||
onClick={() => setSearchParams({ tab: "performance" })}
|
onClick={() => setSearchParams({ tab: "performance" })}
|
||||||
>
|
>
|
||||||
Performance ({psiCount})
|
Performance ({lighthouseCount})
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
@ -150,15 +162,15 @@ function ResultsHeader({
|
|||||||
function StatsGrid({
|
function StatsGrid({
|
||||||
pagesCrawled,
|
pagesCrawled,
|
||||||
totalPages,
|
totalPages,
|
||||||
totalPsi,
|
totalLighthouse,
|
||||||
averageResponseMs,
|
averageResponseMs,
|
||||||
psiSummary,
|
lighthouseSummary,
|
||||||
}: {
|
}: {
|
||||||
pagesCrawled: number;
|
pagesCrawled: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
totalPsi: number;
|
totalLighthouse: number;
|
||||||
averageResponseMs: number;
|
averageResponseMs: number;
|
||||||
psiSummary: {
|
lighthouseSummary: {
|
||||||
failed: number;
|
failed: number;
|
||||||
avgPerformance: number | null;
|
avgPerformance: number | null;
|
||||||
avgSeo: number | null;
|
avgSeo: number | null;
|
||||||
@ -169,37 +181,43 @@ function StatsGrid({
|
|||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||||
<StatCard label="Pages Crawled" value={String(pagesCrawled)} />
|
<StatCard label="Pages Crawled" value={String(pagesCrawled)} />
|
||||||
<StatCard label="Total URLs" value={String(totalPages)} />
|
<StatCard label="Total URLs" value={String(totalPages)} />
|
||||||
<StatCard label="PSI Tests" value={String(totalPsi)} />
|
<StatCard label="Lighthouse Tests" value={String(totalLighthouse)} />
|
||||||
<StatCard label="Avg Response" value={`${averageResponseMs}ms`} />
|
<StatCard label="Avg Response" value={`${averageResponseMs}ms`} />
|
||||||
{totalPsi > 0 && (
|
{totalLighthouse > 0 && (
|
||||||
<>
|
<>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Avg PSI Perf"
|
label="Avg Lighthouse Perf"
|
||||||
value={
|
value={
|
||||||
psiSummary.avgPerformance == null
|
lighthouseSummary.avgPerformance == null
|
||||||
? "-"
|
? "-"
|
||||||
: String(psiSummary.avgPerformance)
|
: String(lighthouseSummary.avgPerformance)
|
||||||
}
|
}
|
||||||
className={scoreClass(psiSummary.avgPerformance)}
|
className={scoreClass(lighthouseSummary.avgPerformance)}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="Avg PSI SEO"
|
label="Avg Lighthouse SEO"
|
||||||
value={psiSummary.avgSeo == null ? "-" : String(psiSummary.avgSeo)}
|
|
||||||
className={scoreClass(psiSummary.avgSeo)}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Avg PSI A11y"
|
|
||||||
value={
|
value={
|
||||||
psiSummary.avgAccessibility == null
|
lighthouseSummary.avgSeo == null
|
||||||
? "-"
|
? "-"
|
||||||
: String(psiSummary.avgAccessibility)
|
: String(lighthouseSummary.avgSeo)
|
||||||
}
|
}
|
||||||
className={scoreClass(psiSummary.avgAccessibility)}
|
className={scoreClass(lighthouseSummary.avgSeo)}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
label="PSI Failures"
|
label="Avg Lighthouse A11y"
|
||||||
value={String(psiSummary.failed)}
|
value={
|
||||||
className={psiSummary.failed > 0 ? "text-error" : "text-success"}
|
lighthouseSummary.avgAccessibility == null
|
||||||
|
? "-"
|
||||||
|
: String(lighthouseSummary.avgAccessibility)
|
||||||
|
}
|
||||||
|
className={scoreClass(lighthouseSummary.avgAccessibility)}
|
||||||
|
/>
|
||||||
|
<StatCard
|
||||||
|
label="Lighthouse Failures"
|
||||||
|
value={String(lighthouseSummary.failed)}
|
||||||
|
className={
|
||||||
|
lighthouseSummary.failed > 0 ? "text-error" : "text-success"
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@ -15,7 +15,7 @@ export function exportPages(
|
|||||||
pages: AuditResultsData["pages"],
|
pages: AuditResultsData["pages"],
|
||||||
format: "csv" | "json",
|
format: "csv" | "json",
|
||||||
) {
|
) {
|
||||||
const rows = pages.map((page) => ({
|
const rows = pages.map((page: AuditResultsData["pages"][number]) => ({
|
||||||
url: page.url,
|
url: page.url,
|
||||||
statusCode: page.statusCode,
|
statusCode: page.statusCode,
|
||||||
title: page.title ?? "",
|
title: page.title ?? "",
|
||||||
@ -45,7 +45,7 @@ export function exportPages(
|
|||||||
"Missing Alt",
|
"Missing Alt",
|
||||||
"Response Time (ms)",
|
"Response Time (ms)",
|
||||||
];
|
];
|
||||||
const lines = rows.map((row) => [
|
const lines = rows.map((row: (typeof rows)[number]) => [
|
||||||
row.url,
|
row.url,
|
||||||
row.statusCode,
|
row.statusCode,
|
||||||
row.title,
|
row.title,
|
||||||
@ -60,12 +60,16 @@ export function exportPages(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function exportPerformance(
|
export function exportPerformance(
|
||||||
psi: AuditResultsData["psi"],
|
lighthouse: AuditResultsData["lighthouse"],
|
||||||
pages: AuditResultsData["pages"],
|
pages: AuditResultsData["pages"],
|
||||||
format: "csv" | "json",
|
format: "csv" | "json",
|
||||||
) {
|
) {
|
||||||
const rows = psi.map((result) => {
|
const rows = lighthouse.map(
|
||||||
const page = pages.find((candidate) => candidate.id === result.pageId);
|
(result: AuditResultsData["lighthouse"][number]) => {
|
||||||
|
const page = pages.find(
|
||||||
|
(candidate: AuditResultsData["pages"][number]) =>
|
||||||
|
candidate.id === result.pageId,
|
||||||
|
);
|
||||||
return {
|
return {
|
||||||
url: page?.url ?? "",
|
url: page?.url ?? "",
|
||||||
strategy: result.strategy,
|
strategy: result.strategy,
|
||||||
@ -77,7 +81,8 @@ export function exportPerformance(
|
|||||||
inpMs: result.inpMs,
|
inpMs: result.inpMs,
|
||||||
ttfbMs: result.ttfbMs,
|
ttfbMs: result.ttfbMs,
|
||||||
};
|
};
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
if (format === "json") {
|
if (format === "json") {
|
||||||
downloadFile(
|
downloadFile(
|
||||||
@ -99,7 +104,7 @@ export function exportPerformance(
|
|||||||
"INP (ms)",
|
"INP (ms)",
|
||||||
"TTFB (ms)",
|
"TTFB (ms)",
|
||||||
];
|
];
|
||||||
const lines = rows.map((row) => [
|
const lines = rows.map((row: (typeof rows)[number]) => [
|
||||||
row.url,
|
row.url,
|
||||||
row.strategy,
|
row.strategy,
|
||||||
row.performance,
|
row.performance,
|
||||||
|
|||||||
@ -70,7 +70,7 @@ export function HttpStatusBadge({ code }: { code: number | null }) {
|
|||||||
return <span className="badge badge-error badge-sm">{code}</span>;
|
return <span className="badge badge-error badge-sm">{code}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PsiScoreBadge({ score }: { score: number | null }) {
|
export function LighthouseScoreBadge({ score }: { score: number | null }) {
|
||||||
if (score == null) {
|
if (score == null) {
|
||||||
return <span className="text-xs text-base-content/40">-</span>;
|
return <span className="text-xs text-base-content/40">-</span>;
|
||||||
}
|
}
|
||||||
|
|||||||
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,
|
Info,
|
||||||
TriangleAlert,
|
TriangleAlert,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { CategoryTab, ExportPayload, PsiIssue } from "./types";
|
import type {
|
||||||
import {
|
CategoryTab,
|
||||||
categoryLabel,
|
ExportPayload,
|
||||||
renderInlineMarkdown,
|
LighthouseIssue,
|
||||||
severityBadgeClass,
|
LighthouseMetrics,
|
||||||
severityIcon,
|
LighthouseScores,
|
||||||
} from "./utils";
|
} from "./types";
|
||||||
|
import { LighthouseIssueRow } from "./LighthouseIssueRow";
|
||||||
|
import { LighthouseIssuesSummary } from "./LighthouseIssuesSummary";
|
||||||
|
import { categoryLabel } from "./utils";
|
||||||
import { categoryTabs } from "./types";
|
import { categoryTabs } from "./types";
|
||||||
|
|
||||||
export function PsiIssuesHeader({
|
export function LighthouseIssuesHeader({
|
||||||
backLabel,
|
backLabel,
|
||||||
onBack,
|
onBack,
|
||||||
scannedAt,
|
scannedAt,
|
||||||
finalUrl,
|
finalUrl,
|
||||||
|
scores,
|
||||||
|
metrics,
|
||||||
severityCounts,
|
severityCounts,
|
||||||
}: {
|
}: {
|
||||||
backLabel: string;
|
backLabel: string;
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
scannedAt?: string;
|
scannedAt?: string;
|
||||||
finalUrl?: string;
|
finalUrl?: string;
|
||||||
|
scores?: LighthouseScores | null;
|
||||||
|
metrics?: LighthouseMetrics | null;
|
||||||
severityCounts: { critical: number; warning: number; info: number };
|
severityCounts: { critical: number; warning: number; info: number };
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
@ -44,11 +51,12 @@ export function PsiIssuesHeader({
|
|||||||
<div className="card bg-base-100 border border-base-300">
|
<div className="card bg-base-100 border border-base-300">
|
||||||
<div className="card-body py-5 gap-4">
|
<div className="card-body py-5 gap-4">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<h1 className="text-2xl font-semibold">PSI Issues</h1>
|
<h1 className="text-2xl font-semibold">Lighthouse Issues</h1>
|
||||||
<p className="text-sm text-base-content/70 break-all">
|
<p className="text-sm text-base-content/70 break-all">
|
||||||
{finalUrl ?? "Loading URL..."}
|
{finalUrl ?? "Loading URL..."}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
<LighthouseIssuesSummary scores={scores} metrics={metrics} />
|
||||||
<div className="flex flex-wrap gap-2 text-xs">
|
<div className="flex flex-wrap gap-2 text-xs">
|
||||||
<span className="badge border border-error/30 bg-error/10 text-error/80 gap-1">
|
<span className="badge border border-error/30 bg-error/10 text-error/80 gap-1">
|
||||||
<FileWarning className="size-3" />
|
<FileWarning className="size-3" />
|
||||||
@ -69,7 +77,7 @@ export function PsiIssuesHeader({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PsiIssuesToolbar({
|
export function LighthouseIssuesToolbar({
|
||||||
category,
|
category,
|
||||||
categoryCounts,
|
categoryCounts,
|
||||||
selectedCategoryLabel,
|
selectedCategoryLabel,
|
||||||
@ -85,12 +93,12 @@ export function PsiIssuesToolbar({
|
|||||||
categoryCounts: Record<CategoryTab, number>;
|
categoryCounts: Record<CategoryTab, number>;
|
||||||
selectedCategoryLabel: string;
|
selectedCategoryLabel: string;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
visibleIssues: PsiIssue[];
|
visibleIssues: LighthouseIssue[];
|
||||||
allIssues: PsiIssue[];
|
allIssues: LighthouseIssue[];
|
||||||
onCategoryChange: (next: CategoryTab) => void;
|
onCategoryChange: (next: CategoryTab) => void;
|
||||||
onCopy: (data: ExportPayload, toastMessage: string) => void;
|
onCopy: (data: ExportPayload, toastMessage: string) => void;
|
||||||
onExport: (data: ExportPayload) => void;
|
onExport: (data: ExportPayload) => void;
|
||||||
onExportCsv: (issues: PsiIssue[], variant: "all" | "current") => void;
|
onExportCsv: (issues: LighthouseIssue[], variant: "all" | "current") => void;
|
||||||
}) {
|
}) {
|
||||||
const exportCurrentCategory: ExportPayload =
|
const exportCurrentCategory: ExportPayload =
|
||||||
category === "all" ? { mode: "issues" } : { mode: "category", category };
|
category === "all" ? { mode: "issues" } : { mode: "category", category };
|
||||||
@ -161,14 +169,14 @@ function ExportMenu({
|
|||||||
onExportCsv,
|
onExportCsv,
|
||||||
visibleIssues,
|
visibleIssues,
|
||||||
}: {
|
}: {
|
||||||
allIssues: PsiIssue[];
|
allIssues: LighthouseIssue[];
|
||||||
categoryLabelLower: string;
|
categoryLabelLower: string;
|
||||||
exportCurrentCategory: ExportPayload;
|
exportCurrentCategory: ExportPayload;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
onCopy: (data: ExportPayload, toastMessage: string) => void;
|
onCopy: (data: ExportPayload, toastMessage: string) => void;
|
||||||
onExport: (data: ExportPayload) => void;
|
onExport: (data: ExportPayload) => void;
|
||||||
onExportCsv: (issues: PsiIssue[], variant: "all" | "current") => void;
|
onExportCsv: (issues: LighthouseIssue[], variant: "all" | "current") => void;
|
||||||
visibleIssues: PsiIssue[];
|
visibleIssues: LighthouseIssue[];
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="dropdown dropdown-end">
|
<div className="dropdown dropdown-end">
|
||||||
@ -201,21 +209,23 @@ function ExportMenu({
|
|||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
onClick={() => onCopy({ mode: "issues" }, "Copied all issues")}
|
onClick={() =>
|
||||||
|
onCopy({ mode: "issues" }, "Copied all actionable issues")
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Copy className="size-4" />
|
<Copy className="size-4" />
|
||||||
Copy all issues
|
Copy all actionable issues
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<button
|
<button
|
||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
onCopy({ mode: "full" }, "Copied full Lighthouse report")
|
onCopy({ mode: "full" }, "Copied saved Lighthouse payload")
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Copy className="size-4" />
|
<Copy className="size-4" />
|
||||||
Copy full Lighthouse report
|
Copy saved Lighthouse payload
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li className="menu-title">
|
<li className="menu-title">
|
||||||
@ -234,12 +244,12 @@ function ExportMenu({
|
|||||||
disabled={isBusy}
|
disabled={isBusy}
|
||||||
onClick={() => onExport({ mode: "issues" })}
|
onClick={() => onExport({ mode: "issues" })}
|
||||||
>
|
>
|
||||||
Download all issues
|
Download all actionable issues
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
<li>
|
||||||
<button disabled={isBusy} onClick={() => onExport({ mode: "full" })}>
|
<button disabled={isBusy} onClick={() => onExport({ mode: "full" })}>
|
||||||
Download full Lighthouse report
|
Download saved Lighthouse payload
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
<li className="menu-title">
|
<li className="menu-title">
|
||||||
@ -258,7 +268,7 @@ function ExportMenu({
|
|||||||
disabled={!allIssues.length}
|
disabled={!allIssues.length}
|
||||||
onClick={() => onExportCsv(allIssues, "all")}
|
onClick={() => onExportCsv(allIssues, "all")}
|
||||||
>
|
>
|
||||||
Download all issues
|
Download all actionable issues
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@ -266,12 +276,14 @@ function ExportMenu({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PsiIssueList({
|
export function LighthouseIssueList({
|
||||||
issues,
|
issues,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
emptyMessage,
|
||||||
}: {
|
}: {
|
||||||
issues: PsiIssue[];
|
issues: LighthouseIssue[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
emptyMessage?: string;
|
||||||
}) {
|
}) {
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return <p className="text-sm text-base-content/60">Loading issues...</p>;
|
return <p className="text-sm text-base-content/60">Loading issues...</p>;
|
||||||
@ -279,84 +291,40 @@ export function PsiIssueList({
|
|||||||
if (!issues.length) {
|
if (!issues.length) {
|
||||||
return (
|
return (
|
||||||
<p className="text-sm text-base-content/60">
|
<p className="text-sm text-base-content/60">
|
||||||
No unresolved issues for this category.
|
{emptyMessage ?? "No actionable issues for this category."}
|
||||||
</p>
|
</p>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<table className="table table-sm w-full table-fixed">
|
||||||
{issues.map((issue) => (
|
<colgroup>
|
||||||
<PsiIssueCard
|
<col className="w-8" />
|
||||||
key={`${issue.category}-${issue.auditKey}`}
|
<col className="w-24" />
|
||||||
|
<col />
|
||||||
|
<col className="w-28 hidden sm:table-column" />
|
||||||
|
<col className="w-28 hidden md:table-column" />
|
||||||
|
<col className="w-14" />
|
||||||
|
</colgroup>
|
||||||
|
<thead>
|
||||||
|
<tr className="text-xs text-base-content/50 uppercase tracking-wide border-b border-base-300">
|
||||||
|
<th />
|
||||||
|
<th className="font-medium">Severity</th>
|
||||||
|
<th className="font-medium">Issue</th>
|
||||||
|
<th className="font-medium hidden sm:table-cell">Category</th>
|
||||||
|
<th className="font-medium hidden md:table-cell text-right">
|
||||||
|
Impact
|
||||||
|
</th>
|
||||||
|
<th className="font-medium text-right">Score</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-base-300/60">
|
||||||
|
{issues.map((issue, issueIndex) => (
|
||||||
|
<LighthouseIssueRow
|
||||||
|
key={`${issue.category}-${issue.auditKey}-${issueIndex}`}
|
||||||
issue={issue}
|
issue={issue}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</tbody>
|
||||||
);
|
</table>
|
||||||
}
|
|
||||||
|
|
||||||
function PsiIssueCard({ issue }: { issue: PsiIssue }) {
|
|
||||||
return (
|
|
||||||
<div className="card bg-base-200/30 border border-base-300">
|
|
||||||
<div className="card-body p-5 gap-3">
|
|
||||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="badge badge-outline">{issue.category}</span>
|
|
||||||
<span
|
|
||||||
className={`badge border ${severityBadgeClass(issue.severity)} gap-1`}
|
|
||||||
>
|
|
||||||
{severityIcon(issue.severity)}
|
|
||||||
{issue.severity}
|
|
||||||
</span>
|
|
||||||
{issue.score != null ? (
|
|
||||||
<div
|
|
||||||
className="tooltip tooltip-top"
|
|
||||||
data-tip="Lighthouse score from 0-100 for this audit. Lower means larger opportunity for improvement."
|
|
||||||
>
|
|
||||||
<span className="badge badge-ghost cursor-help">
|
|
||||||
Score {issue.score}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{issue.impactMs != null || issue.impactBytes != null ? (
|
|
||||||
<span className="text-xs text-base-content/60">
|
|
||||||
Impact {issue.impactMs ?? 0}ms / {issue.impactBytes ?? 0} bytes
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<p className="font-semibold leading-tight">{issue.title}</p>
|
|
||||||
|
|
||||||
{issue.displayValue ? (
|
|
||||||
<p className="text-sm text-base-content/70">{issue.displayValue}</p>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{issue.description ? (
|
|
||||||
<div className="text-sm text-base-content/80 leading-relaxed">
|
|
||||||
{renderInlineMarkdown(issue.description)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{issue.items.length > 0 ? (
|
|
||||||
<details className="text-sm bg-base-100 rounded-box border border-base-300/80 px-3 py-2">
|
|
||||||
<summary className="cursor-pointer font-medium text-base-content/75">
|
|
||||||
Affected items ({issue.items.length})
|
|
||||||
</summary>
|
|
||||||
<div className="mt-2 space-y-2">
|
|
||||||
{issue.items.map((item) => (
|
|
||||||
<pre
|
|
||||||
key={`${issue.auditKey}-${item}`}
|
|
||||||
className="bg-base-200/60 p-2 rounded-box overflow-x-auto text-xs"
|
|
||||||
>
|
|
||||||
{item}
|
|
||||||
</pre>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -1,7 +1,11 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
|
import { AlertCircle, TriangleAlert } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { exportAuditPsi, getAuditPsiIssues } from "@/serverFunctions/psi";
|
import {
|
||||||
import type { CategoryTab, ExportPayload, PsiIssue } from "./types";
|
exportAuditLighthouseIssues,
|
||||||
|
getAuditLighthouseIssues,
|
||||||
|
} from "@/serverFunctions/lighthouse";
|
||||||
|
import type { CategoryTab, ExportPayload, LighthouseIssue } from "./types";
|
||||||
import {
|
import {
|
||||||
categoryLabel,
|
categoryLabel,
|
||||||
categorySlug,
|
categorySlug,
|
||||||
@ -9,13 +13,13 @@ import {
|
|||||||
issuesToCsv,
|
issuesToCsv,
|
||||||
} from "./utils";
|
} from "./utils";
|
||||||
import {
|
import {
|
||||||
PsiIssueList,
|
LighthouseIssueList,
|
||||||
PsiIssuesHeader,
|
LighthouseIssuesHeader,
|
||||||
PsiIssuesToolbar,
|
LighthouseIssuesToolbar,
|
||||||
} from "./PsiIssuesParts";
|
} from "./LighthouseIssuesParts";
|
||||||
import { categoryTabs } from "./types";
|
import { categoryTabs } from "./types";
|
||||||
|
|
||||||
type PsiIssuesScreenProps = {
|
type LighthouseIssuesScreenProps = {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
resultId: string;
|
resultId: string;
|
||||||
category: CategoryTab;
|
category: CategoryTab;
|
||||||
@ -24,26 +28,14 @@ type PsiIssuesScreenProps = {
|
|||||||
onCategoryChange: (next: CategoryTab) => void;
|
onCategoryChange: (next: CategoryTab) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PsiIssuesScreen(props: PsiIssuesScreenProps) {
|
export function LighthouseIssuesScreen(props: LighthouseIssuesScreenProps) {
|
||||||
const { projectId, resultId, category, backLabel, onBack, onCategoryChange } =
|
const { projectId, resultId, category, backLabel, onBack, onCategoryChange } =
|
||||||
props;
|
props;
|
||||||
|
|
||||||
const issuesQuery = useQuery({
|
const issuesQuery = useQuery({
|
||||||
queryKey: ["auditPsiIssues", projectId, resultId, category],
|
queryKey: ["auditLighthouseIssues", projectId, resultId],
|
||||||
queryFn: () =>
|
queryFn: () =>
|
||||||
getAuditPsiIssues({
|
getAuditLighthouseIssues({
|
||||||
data: {
|
|
||||||
projectId,
|
|
||||||
resultId,
|
|
||||||
category: category === "all" ? undefined : category,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const summaryQuery = useQuery({
|
|
||||||
queryKey: ["auditPsiIssuesSummary", projectId, resultId],
|
|
||||||
queryFn: () =>
|
|
||||||
getAuditPsiIssues({
|
|
||||||
data: {
|
data: {
|
||||||
projectId,
|
projectId,
|
||||||
resultId,
|
resultId,
|
||||||
@ -52,8 +44,10 @@ export function PsiIssuesScreen(props: PsiIssuesScreenProps) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const exportMutation = useMutation({
|
const exportMutation = useMutation({
|
||||||
mutationFn: (data: ExportPayload) =>
|
mutationFn: (
|
||||||
exportAuditPsi({
|
data: ExportPayload,
|
||||||
|
): Promise<{ filename: string; content: string }> =>
|
||||||
|
exportAuditLighthouseIssues({
|
||||||
data: {
|
data: {
|
||||||
projectId,
|
projectId,
|
||||||
resultId,
|
resultId,
|
||||||
@ -71,27 +65,56 @@ export function PsiIssuesScreen(props: PsiIssuesScreenProps) {
|
|||||||
selectedCategoryLabel,
|
selectedCategoryLabel,
|
||||||
severityCounts,
|
severityCounts,
|
||||||
visibleIssues,
|
visibleIssues,
|
||||||
} = usePsiIssuesActions({
|
} = useLighthouseIssuesActions({
|
||||||
category,
|
category,
|
||||||
exportMutation,
|
exportMutation,
|
||||||
issues: (issuesQuery.data?.issues ?? []) as PsiIssue[],
|
allIssues: issuesQuery.data?.issues ?? [],
|
||||||
summaryIssues: summaryQuery.data?.issues,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const issuesErrorMessage =
|
||||||
|
issuesQuery.error instanceof Error
|
||||||
|
? issuesQuery.error.message
|
||||||
|
: "Failed to load Lighthouse issues.";
|
||||||
|
const showsLegacyPayloadNotice =
|
||||||
|
issuesQuery.data != null && !issuesQuery.data.hasIssueDetails;
|
||||||
|
const emptyMessage = showsLegacyPayloadNotice
|
||||||
|
? "This audit was saved without issue-level Lighthouse details. Re-run the audit to populate this screen."
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 py-3 md:px-6 md:py-4 pb-24 md:pb-8 overflow-auto">
|
<div className="px-4 py-3 md:px-6 md:py-4 pb-24 md:pb-8 overflow-auto">
|
||||||
<div className="mx-auto max-w-5xl space-y-4">
|
<div className="mx-auto max-w-5xl space-y-4">
|
||||||
<PsiIssuesHeader
|
<LighthouseIssuesHeader
|
||||||
backLabel={backLabel}
|
backLabel={backLabel}
|
||||||
onBack={onBack}
|
onBack={onBack}
|
||||||
scannedAt={issuesQuery.data?.createdAt}
|
scannedAt={issuesQuery.data?.createdAt}
|
||||||
finalUrl={issuesQuery.data?.finalUrl}
|
finalUrl={issuesQuery.data?.finalUrl}
|
||||||
|
scores={issuesQuery.data?.scores}
|
||||||
|
metrics={issuesQuery.data?.metrics}
|
||||||
severityCounts={severityCounts}
|
severityCounts={severityCounts}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="card bg-base-100 border border-base-300">
|
<div className="card bg-base-100 border border-base-300">
|
||||||
<div className="card-body gap-4">
|
<div className="card-body gap-4">
|
||||||
<PsiIssuesToolbar
|
{issuesQuery.isError ? (
|
||||||
|
<div className="alert alert-error">
|
||||||
|
<AlertCircle className="size-4" />
|
||||||
|
<span>{issuesErrorMessage}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{showsLegacyPayloadNotice ? (
|
||||||
|
<div className="alert alert-warning">
|
||||||
|
<TriangleAlert className="size-4" />
|
||||||
|
<span>
|
||||||
|
This Lighthouse run was stored before issue details were
|
||||||
|
preserved. Re-run the audit to see category counts and issue
|
||||||
|
cards.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<LighthouseIssuesToolbar
|
||||||
category={category}
|
category={category}
|
||||||
categoryCounts={categoryCounts}
|
categoryCounts={categoryCounts}
|
||||||
selectedCategoryLabel={selectedCategoryLabel}
|
selectedCategoryLabel={selectedCategoryLabel}
|
||||||
@ -107,9 +130,10 @@ export function PsiIssuesScreen(props: PsiIssuesScreenProps) {
|
|||||||
}}
|
}}
|
||||||
onExportCsv={runExportCsv}
|
onExportCsv={runExportCsv}
|
||||||
/>
|
/>
|
||||||
<PsiIssueList
|
<LighthouseIssueList
|
||||||
issues={visibleIssues}
|
issues={visibleIssues}
|
||||||
isLoading={issuesQuery.isLoading}
|
isLoading={issuesQuery.isLoading}
|
||||||
|
emptyMessage={emptyMessage}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -118,23 +142,23 @@ export function PsiIssuesScreen(props: PsiIssuesScreenProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function usePsiIssuesActions({
|
function useLighthouseIssuesActions({
|
||||||
|
allIssues,
|
||||||
category,
|
category,
|
||||||
exportMutation,
|
exportMutation,
|
||||||
issues,
|
|
||||||
summaryIssues,
|
|
||||||
}: {
|
}: {
|
||||||
|
allIssues: LighthouseIssue[];
|
||||||
category: CategoryTab;
|
category: CategoryTab;
|
||||||
exportMutation: {
|
exportMutation: {
|
||||||
mutateAsync: (
|
mutateAsync: (
|
||||||
data: ExportPayload,
|
data: ExportPayload,
|
||||||
) => Promise<{ filename: string; content: string }>;
|
) => Promise<{ filename: string; content: string }>;
|
||||||
};
|
};
|
||||||
issues: PsiIssue[];
|
|
||||||
summaryIssues: PsiIssue[] | undefined;
|
|
||||||
}) {
|
}) {
|
||||||
const visibleIssues = issues;
|
const visibleIssues =
|
||||||
const allIssues = summaryIssues ?? visibleIssues;
|
category === "all"
|
||||||
|
? allIssues
|
||||||
|
: allIssues.filter((issue) => issue.category === category);
|
||||||
const selectedCategoryLabel = categoryLabel(category);
|
const selectedCategoryLabel = categoryLabel(category);
|
||||||
const categoryCounts = getCategoryCounts(allIssues);
|
const categoryCounts = getCategoryCounts(allIssues);
|
||||||
const severityCounts = getSeverityCounts(visibleIssues);
|
const severityCounts = getSeverityCounts(visibleIssues);
|
||||||
@ -151,8 +175,11 @@ function usePsiIssuesActions({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const runExportCsv = (rows: PsiIssue[], variant: "all" | "current") => {
|
const runExportCsv = (
|
||||||
const filename = `psi-${variant}-${categorySlug(category)}-issues.csv`;
|
rows: LighthouseIssue[],
|
||||||
|
variant: "all" | "current",
|
||||||
|
) => {
|
||||||
|
const filename = `lighthouse-${variant}-${categorySlug(category)}-issues.csv`;
|
||||||
downloadTextFile(filename, issuesToCsv(rows), "text/csv");
|
downloadTextFile(filename, issuesToCsv(rows), "text/csv");
|
||||||
toast.success("CSV download started");
|
toast.success("CSV download started");
|
||||||
};
|
};
|
||||||
@ -181,7 +208,9 @@ function usePsiIssuesActions({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCategoryCounts(allIssues: PsiIssue[]): Record<CategoryTab, number> {
|
function getCategoryCounts(
|
||||||
|
allIssues: LighthouseIssue[],
|
||||||
|
): Record<CategoryTab, number> {
|
||||||
return categoryTabs.reduce<Record<CategoryTab, number>>(
|
return categoryTabs.reduce<Record<CategoryTab, number>>(
|
||||||
(acc, tab) => {
|
(acc, tab) => {
|
||||||
if (tab === "all") {
|
if (tab === "all") {
|
||||||
@ -201,7 +230,7 @@ function getCategoryCounts(allIssues: PsiIssue[]): Record<CategoryTab, number> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getSeverityCounts(issues: PsiIssue[]) {
|
function getSeverityCounts(issues: LighthouseIssue[]) {
|
||||||
return {
|
return {
|
||||||
critical: issues.filter((issue) => issue.severity === "critical").length,
|
critical: issues.filter((issue) => issue.severity === "critical").length,
|
||||||
warning: issues.filter((issue) => issue.severity === "warning").length,
|
warning: issues.filter((issue) => issue.severity === "warning").length,
|
||||||
@ -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" }),
|
.references(() => organization.id, { onDelete: "cascade" }),
|
||||||
name: text("name").notNull(),
|
name: text("name").notNull(),
|
||||||
domain: text("domain"),
|
domain: text("domain"),
|
||||||
// PSI keys are used for Google API abuse-control, not direct billing.
|
|
||||||
// We still keep handling explicit to make the tradeoff obvious.
|
|
||||||
pagespeedApiKey: text("pagespeed_api_key"),
|
|
||||||
createdAt: text("created_at")
|
createdAt: text("created_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
.default(sql`(current_timestamp)`),
|
.default(sql`(current_timestamp)`),
|
||||||
@ -123,14 +120,14 @@ export const audits = sqliteTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default("running"),
|
.default("running"),
|
||||||
workflowInstanceId: text("workflow_instance_id"),
|
workflowInstanceId: text("workflow_instance_id"),
|
||||||
// JSON config: { maxPages, psiStrategy, psiApiKey? }
|
// JSON config: { maxPages, lighthouseStrategy }
|
||||||
config: text("config").notNull().default("{}"),
|
config: text("config").notNull().default("{}"),
|
||||||
// Progress & summary
|
// Progress & summary
|
||||||
pagesCrawled: integer("pages_crawled").notNull().default(0),
|
pagesCrawled: integer("pages_crawled").notNull().default(0),
|
||||||
pagesTotal: integer("pages_total").notNull().default(0),
|
pagesTotal: integer("pages_total").notNull().default(0),
|
||||||
psiTotal: integer("psi_total").notNull().default(0),
|
lighthouseTotal: integer("lighthouse_total").notNull().default(0),
|
||||||
psiCompleted: integer("psi_completed").notNull().default(0),
|
lighthouseCompleted: integer("lighthouse_completed").notNull().default(0),
|
||||||
psiFailed: integer("psi_failed").notNull().default(0),
|
lighthouseFailed: integer("lighthouse_failed").notNull().default(0),
|
||||||
currentPhase: text("current_phase").default("discovery"),
|
currentPhase: text("current_phase").default("discovery"),
|
||||||
startedAt: text("started_at")
|
startedAt: text("started_at")
|
||||||
.notNull()
|
.notNull()
|
||||||
@ -196,10 +193,9 @@ export const auditPages = sqliteTable(
|
|||||||
(table) => [index("audit_pages_audit_id_idx").on(table.auditId)],
|
(table) => [index("audit_pages_audit_id_idx").on(table.auditId)],
|
||||||
);
|
);
|
||||||
|
|
||||||
// PSI summaries captured as part of a site audit run.
|
// One row per Lighthouse test (mobile + desktop per page).
|
||||||
// These belong to audit pages and are the only PSI result records we keep.
|
export const auditLighthouseResults = sqliteTable(
|
||||||
export const auditPsiResults = sqliteTable(
|
"audit_lighthouse_results",
|
||||||
"audit_psi_results",
|
|
||||||
{
|
{
|
||||||
id: text("id").primaryKey(),
|
id: text("id").primaryKey(),
|
||||||
auditId: text("audit_id")
|
auditId: text("audit_id")
|
||||||
@ -221,5 +217,5 @@ export const auditPsiResults = sqliteTable(
|
|||||||
r2Key: text("r2_key"),
|
r2Key: text("r2_key"),
|
||||||
payloadSizeBytes: integer("payload_size_bytes"),
|
payloadSizeBytes: integer("payload_size_bytes"),
|
||||||
},
|
},
|
||||||
(table) => [index("audit_psi_results_audit_id_idx").on(table.auditId)],
|
(table) => [index("audit_lighthouse_results_audit_id_idx").on(table.auditId)],
|
||||||
);
|
);
|
||||||
|
|||||||
@ -199,9 +199,9 @@ function ProgressCard({
|
|||||||
status: {
|
status: {
|
||||||
pagesCrawled: number;
|
pagesCrawled: number;
|
||||||
pagesTotal: number;
|
pagesTotal: number;
|
||||||
psiTotal: number;
|
lighthouseTotal: number;
|
||||||
psiCompleted: number;
|
lighthouseCompleted: number;
|
||||||
psiFailed: number;
|
lighthouseFailed: number;
|
||||||
currentPhase: string | null;
|
currentPhase: string | null;
|
||||||
};
|
};
|
||||||
}) {
|
}) {
|
||||||
@ -209,21 +209,23 @@ function ProgressCard({
|
|||||||
status.pagesTotal > 0
|
status.pagesTotal > 0
|
||||||
? Math.round((status.pagesCrawled / status.pagesTotal) * 100)
|
? Math.round((status.pagesCrawled / status.pagesTotal) * 100)
|
||||||
: 0;
|
: 0;
|
||||||
const psiDone = status.psiCompleted + status.psiFailed;
|
const lighthouseDone = status.lighthouseCompleted + status.lighthouseFailed;
|
||||||
const psiProgress =
|
const lighthouseProgress =
|
||||||
status.psiTotal > 0 ? Math.round((psiDone / status.psiTotal) * 100) : 0;
|
status.lighthouseTotal > 0
|
||||||
const isPsiPhase = status.currentPhase === "psi";
|
? Math.round((lighthouseDone / status.lighthouseTotal) * 100)
|
||||||
|
: 0;
|
||||||
|
const isLighthousePhase = status.currentPhase === "lighthouse";
|
||||||
const phaseLabel =
|
const phaseLabel =
|
||||||
status.currentPhase === "discovery"
|
status.currentPhase === "discovery"
|
||||||
? "Discovery"
|
? "Discovery"
|
||||||
: status.currentPhase === "crawling"
|
: status.currentPhase === "crawling"
|
||||||
? "Crawling"
|
? "Crawling"
|
||||||
: status.currentPhase === "psi"
|
: status.currentPhase === "lighthouse"
|
||||||
? "PSI"
|
? "Lighthouse"
|
||||||
: status.currentPhase === "finalizing"
|
: status.currentPhase === "finalizing"
|
||||||
? "Finalizing"
|
? "Finalizing"
|
||||||
: (status.currentPhase ?? "Running");
|
: (status.currentPhase ?? "Running");
|
||||||
const progress = isPsiPhase ? psiProgress : crawlProgress;
|
const progress = isLighthousePhase ? lighthouseProgress : crawlProgress;
|
||||||
|
|
||||||
const crawlProgressQuery = useQuery({
|
const crawlProgressQuery = useQuery({
|
||||||
queryKey: ["audit-crawl-progress", projectId, auditId],
|
queryKey: ["audit-crawl-progress", projectId, auditId],
|
||||||
@ -240,7 +242,9 @@ function ProgressCard({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h2 className="font-medium flex items-center gap-2">
|
<h2 className="font-medium flex items-center gap-2">
|
||||||
<Loader2 className="size-4 animate-spin text-primary" />
|
<Loader2 className="size-4 animate-spin text-primary" />
|
||||||
{isPsiPhase ? "Running PSI checks" : "Crawling pages"}
|
{isLighthousePhase
|
||||||
|
? "Running Lighthouse checks"
|
||||||
|
: "Crawling pages"}
|
||||||
</h2>
|
</h2>
|
||||||
<span className="badge badge-ghost badge-sm">{phaseLabel}</span>
|
<span className="badge badge-ghost badge-sm">{phaseLabel}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -252,10 +256,12 @@ function ProgressCard({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
<div className="flex items-center justify-between text-sm">
|
||||||
{isPsiPhase ? (
|
{isLighthousePhase ? (
|
||||||
<span>
|
<span>
|
||||||
{psiDone} / {status.psiTotal} checks
|
{lighthouseDone} / {status.lighthouseTotal} checks
|
||||||
{status.psiFailed > 0 ? ` (${status.psiFailed} failed)` : ""}
|
{status.lighthouseFailed > 0
|
||||||
|
? ` (${status.lighthouseFailed} failed)`
|
||||||
|
: ""}
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
<span>
|
<span>
|
||||||
|
|||||||
@ -1,21 +1,21 @@
|
|||||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||||
import { PsiIssuesScreen } from "@/client/features/psi/issues/PsiIssuesScreen";
|
import { LighthouseIssuesScreen } from "@/client/features/lighthouse/issues/LighthouseIssuesScreen";
|
||||||
import { psiIssuesSearchSchema } from "@/types/schemas/psi";
|
import { lighthouseIssuesSearchSchema } from "@/types/schemas/lighthouse";
|
||||||
|
|
||||||
export const Route = createFileRoute(
|
export const Route = createFileRoute(
|
||||||
"/_project/p/$projectId/audit/issues/$resultId",
|
"/_project/p/$projectId/audit/issues/$resultId",
|
||||||
)({
|
)({
|
||||||
validateSearch: psiIssuesSearchSchema,
|
validateSearch: lighthouseIssuesSearchSchema,
|
||||||
component: AuditIssuesPage,
|
component: AuditIssuesPage,
|
||||||
});
|
});
|
||||||
|
|
||||||
function AuditIssuesPage() {
|
function AuditIssuesPage() {
|
||||||
const { projectId, resultId } = Route.useParams();
|
const { projectId, resultId } = Route.useParams();
|
||||||
const { category } = Route.useSearch();
|
const { auditId, category } = Route.useSearch();
|
||||||
const navigate = useNavigate({ from: Route.fullPath });
|
const navigate = useNavigate({ from: Route.fullPath });
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PsiIssuesScreen
|
<LighthouseIssuesScreen
|
||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
resultId={resultId}
|
resultId={resultId}
|
||||||
category={category}
|
category={category}
|
||||||
@ -24,6 +24,7 @@ function AuditIssuesPage() {
|
|||||||
void navigate({
|
void navigate({
|
||||||
to: "/p/$projectId/audit",
|
to: "/p/$projectId/audit",
|
||||||
params: { projectId },
|
params: { projectId },
|
||||||
|
search: auditId ? { auditId } : undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
onCategoryChange={(next) =>
|
onCategoryChange={(next) =>
|
||||||
|
|||||||
@ -1,13 +1,30 @@
|
|||||||
/**
|
/**
|
||||||
* Data access layer for site audit tables.
|
* Data access layer for site audit tables.
|
||||||
* All D1 interactions for audits, audit_pages, and audit_psi_results.
|
* All D1 interactions for audits, audit_pages, and stored Lighthouse results.
|
||||||
*/
|
*/
|
||||||
import { db } from "@/db";
|
|
||||||
import { audits, auditPages, auditPsiResults } from "@/db/schema";
|
|
||||||
import { and, desc, eq } from "drizzle-orm";
|
import { and, desc, eq } from "drizzle-orm";
|
||||||
import type { PsiResult, AuditConfig } from "@/server/lib/audit/types";
|
import { db } from "@/db";
|
||||||
|
import { audits, auditLighthouseResults, auditPages } from "@/db/schema";
|
||||||
|
import type {
|
||||||
|
AuditConfig,
|
||||||
|
LighthouseResult,
|
||||||
|
StepPageResult,
|
||||||
|
} from "@/server/lib/audit/types";
|
||||||
|
|
||||||
// ─── Create ──────────────────────────────────────────────────────────────────
|
const DB_BATCH_SIZE = 100;
|
||||||
|
type BatchStatement = Parameters<typeof db.batch>[0][number];
|
||||||
|
|
||||||
|
async function executeInBatches<T>(
|
||||||
|
items: T[],
|
||||||
|
buildStatement: (item: T) => BatchStatement,
|
||||||
|
) {
|
||||||
|
for (let i = 0; i < items.length; i += DB_BATCH_SIZE) {
|
||||||
|
const chunk = items.slice(i, i + DB_BATCH_SIZE).map(buildStatement);
|
||||||
|
const [first, ...rest] = chunk;
|
||||||
|
if (!first) continue;
|
||||||
|
await db.batch([first, ...rest]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function createAudit(data: {
|
async function createAudit(data: {
|
||||||
id: string;
|
id: string;
|
||||||
@ -17,7 +34,7 @@ async function createAudit(data: {
|
|||||||
workflowInstanceId: string;
|
workflowInstanceId: string;
|
||||||
config: AuditConfig;
|
config: AuditConfig;
|
||||||
pagesTotal: number;
|
pagesTotal: number;
|
||||||
psiTotal: number;
|
lighthouseTotal: number;
|
||||||
}) {
|
}) {
|
||||||
await db.insert(audits).values({
|
await db.insert(audits).values({
|
||||||
id: data.id,
|
id: data.id,
|
||||||
@ -28,22 +45,20 @@ async function createAudit(data: {
|
|||||||
config: JSON.stringify(data.config),
|
config: JSON.stringify(data.config),
|
||||||
status: "running",
|
status: "running",
|
||||||
pagesTotal: data.pagesTotal,
|
pagesTotal: data.pagesTotal,
|
||||||
psiTotal: data.psiTotal,
|
lighthouseTotal: data.lighthouseTotal,
|
||||||
currentPhase: "discovery",
|
currentPhase: "discovery",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Update ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function updateAuditProgress(
|
async function updateAuditProgress(
|
||||||
auditId: string,
|
auditId: string,
|
||||||
workflowInstanceId: string,
|
workflowInstanceId: string,
|
||||||
data: {
|
data: {
|
||||||
pagesCrawled?: number;
|
pagesCrawled?: number;
|
||||||
pagesTotal?: number;
|
pagesTotal?: number;
|
||||||
psiTotal?: number;
|
lighthouseTotal?: number;
|
||||||
psiCompleted?: number;
|
lighthouseCompleted?: number;
|
||||||
psiFailed?: number;
|
lighthouseFailed?: number;
|
||||||
currentPhase?: string;
|
currentPhase?: string;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
@ -110,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(
|
async function batchWriteResults(
|
||||||
auditId: string,
|
auditId: string,
|
||||||
pages: Array<{
|
pages: StepPageResult[],
|
||||||
id: string;
|
lighthouseResults: LighthouseResult[],
|
||||||
url: string;
|
|
||||||
statusCode: number;
|
|
||||||
redirectUrl: string | null;
|
|
||||||
title: string;
|
|
||||||
metaDescription: string;
|
|
||||||
canonicalUrl: string | null;
|
|
||||||
robotsMeta: string | null;
|
|
||||||
ogTitle: string | null;
|
|
||||||
ogDescription: string | null;
|
|
||||||
ogImage: string | null;
|
|
||||||
h1Count: number;
|
|
||||||
h2Count: number;
|
|
||||||
h3Count: number;
|
|
||||||
h4Count: number;
|
|
||||||
h5Count: number;
|
|
||||||
h6Count: number;
|
|
||||||
headingOrder: number[];
|
|
||||||
wordCount: number;
|
|
||||||
imagesTotal: number;
|
|
||||||
imagesMissingAlt: number;
|
|
||||||
images: Array<{ src: string | null; alt: string | null }>;
|
|
||||||
internalLinks: string[];
|
|
||||||
externalLinks: string[];
|
|
||||||
hasStructuredData: boolean;
|
|
||||||
hreflangTags: string[];
|
|
||||||
isIndexable: boolean;
|
|
||||||
responseTimeMs: number;
|
|
||||||
}>,
|
|
||||||
psiResults: PsiResult[],
|
|
||||||
) {
|
) {
|
||||||
const BATCH_SIZE = 100; // D1 max statements per batch() call
|
await executeInBatches(pages, (page) =>
|
||||||
|
|
||||||
// ── Pages ──────────────────────────────────────────────────────────
|
|
||||||
const pageStatements = pages.map((p) =>
|
|
||||||
db.insert(auditPages).values({
|
db.insert(auditPages).values({
|
||||||
id: p.id,
|
id: page.id,
|
||||||
auditId,
|
auditId,
|
||||||
url: p.url,
|
url: page.url,
|
||||||
statusCode: p.statusCode,
|
statusCode: page.statusCode,
|
||||||
redirectUrl: p.redirectUrl,
|
redirectUrl: page.redirectUrl,
|
||||||
// Metadata
|
title: page.title,
|
||||||
title: p.title,
|
metaDescription: page.metaDescription,
|
||||||
metaDescription: p.metaDescription,
|
canonicalUrl: page.canonicalUrl,
|
||||||
canonicalUrl: p.canonicalUrl,
|
robotsMeta: page.robotsMeta,
|
||||||
robotsMeta: p.robotsMeta,
|
ogTitle: page.ogTitle,
|
||||||
// Open Graph
|
ogDescription: page.ogDescription,
|
||||||
ogTitle: p.ogTitle,
|
ogImage: page.ogImage,
|
||||||
ogDescription: p.ogDescription,
|
h1Count: page.h1Count,
|
||||||
ogImage: p.ogImage,
|
h2Count: page.h2Count,
|
||||||
// Headings
|
h3Count: page.h3Count,
|
||||||
h1Count: p.h1Count,
|
h4Count: page.h4Count,
|
||||||
h2Count: p.h2Count,
|
h5Count: page.h5Count,
|
||||||
h3Count: p.h3Count,
|
h6Count: page.h6Count,
|
||||||
h4Count: p.h4Count,
|
headingOrderJson: JSON.stringify(page.headingOrder),
|
||||||
h5Count: p.h5Count,
|
wordCount: page.wordCount,
|
||||||
h6Count: p.h6Count,
|
imagesTotal: page.imagesTotal,
|
||||||
headingOrderJson: JSON.stringify(p.headingOrder),
|
imagesMissingAlt: page.imagesMissingAlt,
|
||||||
// Content
|
imagesJson: JSON.stringify(page.images),
|
||||||
wordCount: p.wordCount,
|
internalLinkCount: page.internalLinks.length,
|
||||||
// Images
|
externalLinkCount: page.externalLinks.length,
|
||||||
imagesTotal: p.imagesTotal,
|
hasStructuredData: page.hasStructuredData,
|
||||||
imagesMissingAlt: p.imagesMissingAlt,
|
hreflangTagsJson: JSON.stringify(page.hreflangTags),
|
||||||
imagesJson: JSON.stringify(p.images),
|
isIndexable: page.isIndexable,
|
||||||
// Links
|
responseTimeMs: page.responseTimeMs,
|
||||||
internalLinkCount: p.internalLinks.length,
|
|
||||||
externalLinkCount: p.externalLinks.length,
|
|
||||||
// Structured data
|
|
||||||
hasStructuredData: p.hasStructuredData,
|
|
||||||
// Hreflang
|
|
||||||
hreflangTagsJson: JSON.stringify(p.hreflangTags),
|
|
||||||
// Indexability
|
|
||||||
isIndexable: p.isIndexable,
|
|
||||||
// Performance
|
|
||||||
responseTimeMs: p.responseTimeMs,
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
for (let i = 0; i < pageStatements.length; i += BATCH_SIZE) {
|
if (lighthouseResults.length === 0) {
|
||||||
const chunk = pageStatements.slice(i, i + BATCH_SIZE);
|
return;
|
||||||
const [first, ...rest] = chunk;
|
|
||||||
await db.batch([first, ...rest]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── PSI results ────────────────────────────────────────────────────
|
await executeInBatches(lighthouseResults, (result) =>
|
||||||
if (psiResults.length > 0) {
|
db.insert(auditLighthouseResults).values({
|
||||||
const psiStatements = psiResults.map((r) =>
|
|
||||||
db.insert(auditPsiResults).values({
|
|
||||||
id: crypto.randomUUID(),
|
id: crypto.randomUUID(),
|
||||||
auditId,
|
auditId,
|
||||||
pageId: r.pageId,
|
pageId: result.pageId,
|
||||||
strategy: r.strategy,
|
strategy: result.strategy,
|
||||||
performanceScore: r.performanceScore,
|
performanceScore: result.performanceScore,
|
||||||
accessibilityScore: r.accessibilityScore,
|
accessibilityScore: result.accessibilityScore,
|
||||||
bestPracticesScore: r.bestPracticesScore,
|
bestPracticesScore: result.bestPracticesScore,
|
||||||
seoScore: r.seoScore,
|
seoScore: result.seoScore,
|
||||||
lcpMs: r.lcpMs,
|
lcpMs: result.lcpMs,
|
||||||
cls: r.cls,
|
cls: result.cls,
|
||||||
inpMs: r.inpMs,
|
inpMs: result.inpMs,
|
||||||
ttfbMs: r.ttfbMs,
|
ttfbMs: result.ttfbMs,
|
||||||
errorMessage: r.errorMessage ?? null,
|
errorMessage: result.errorMessage ?? null,
|
||||||
r2Key: r.r2Key ?? null,
|
r2Key: result.r2Key ?? null,
|
||||||
payloadSizeBytes: r.payloadSizeBytes ?? null,
|
payloadSizeBytes: result.payloadSizeBytes ?? null,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
for (let i = 0; i < psiStatements.length; i += BATCH_SIZE) {
|
|
||||||
const chunk = psiStatements.slice(i, i + BATCH_SIZE);
|
|
||||||
const [first, ...rest] = chunk;
|
|
||||||
await db.batch([first, ...rest]);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ─── Read ────────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function getAuditForProject(auditId: string, projectId: string) {
|
async function getAuditForProject(auditId: string, projectId: string) {
|
||||||
return db.query.audits.findFirst({
|
return db.query.audits.findFirst({
|
||||||
@ -252,78 +205,80 @@ async function getAuditsByProject(projectId: string) {
|
|||||||
return rows.map(({ audit }) => audit);
|
return rows.map(({ audit }) => audit);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getAuditResultsForProject(auditId: string, projectId: string) {
|
|
||||||
const audit = await getAuditForProject(auditId, projectId);
|
|
||||||
if (!audit) {
|
|
||||||
return { audit: null, pages: [], psi: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const [pages, psi] = await Promise.all([
|
|
||||||
db.query.auditPages.findMany({
|
|
||||||
where: eq(auditPages.auditId, auditId),
|
|
||||||
}),
|
|
||||||
db.query.auditPsiResults.findMany({
|
|
||||||
where: eq(auditPsiResults.auditId, auditId),
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return { audit, pages, psi };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getAuditCapacityUsageForUser(userId: string) {
|
async function getAuditCapacityUsageForUser(userId: string) {
|
||||||
const rows = await db.query.audits.findMany({
|
const rows = await db.query.audits.findMany({
|
||||||
where: eq(audits.startedByUserId, userId),
|
where: eq(audits.startedByUserId, userId),
|
||||||
columns: {
|
columns: {
|
||||||
pagesTotal: true,
|
pagesTotal: true,
|
||||||
psiTotal: true,
|
lighthouseTotal: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return rows.reduce((total, row) => total + row.pagesTotal + row.psiTotal, 0);
|
return rows.reduce(
|
||||||
|
(total, row) => total + row.pagesTotal + row.lighthouseTotal,
|
||||||
|
0,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getPsiResultById(input: {
|
async function getAuditResultsForProject(auditId: string, projectId: string) {
|
||||||
psiResultId: string;
|
const audit = await getAuditForProject(auditId, projectId);
|
||||||
|
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;
|
projectId: string;
|
||||||
}) {
|
}) {
|
||||||
const psi = await db.query.auditPsiResults.findFirst({
|
const lighthouse = await db.query.auditLighthouseResults.findFirst({
|
||||||
where: eq(auditPsiResults.id, input.psiResultId),
|
where: eq(auditLighthouseResults.id, input.lighthouseResultId),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!psi) return null;
|
if (!lighthouse) {
|
||||||
|
return null;
|
||||||
const parentAudit = await db.query.audits.findFirst({
|
|
||||||
where: and(
|
|
||||||
eq(audits.id, psi.auditId),
|
|
||||||
eq(audits.projectId, input.projectId),
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!parentAudit) {
|
|
||||||
throw new Error("Audit not found");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const page = await db.query.auditPages.findFirst({
|
const [parentAudit, page] = await Promise.all([
|
||||||
where: eq(auditPages.id, psi.pageId),
|
db.query.audits.findFirst({
|
||||||
});
|
where: and(
|
||||||
|
eq(audits.id, lighthouse.auditId),
|
||||||
|
eq(audits.projectId, input.projectId),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
db.query.auditPages.findFirst({
|
||||||
|
where: eq(auditPages.id, lighthouse.pageId),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!parentAudit) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
psi,
|
lighthouse,
|
||||||
page,
|
page,
|
||||||
audit: parentAudit,
|
audit: parentAudit,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Delete ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
async function deleteAuditForProject(auditId: string, projectId: string) {
|
async function deleteAuditForProject(auditId: string, projectId: string) {
|
||||||
await db
|
await db
|
||||||
.delete(audits)
|
.delete(audits)
|
||||||
.where(and(eq(audits.id, auditId), eq(audits.projectId, projectId)));
|
.where(and(eq(audits.id, auditId), eq(audits.projectId, projectId)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Export ──────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
export const AuditRepository = {
|
export const AuditRepository = {
|
||||||
createAudit,
|
createAudit,
|
||||||
updateAuditProgress,
|
updateAuditProgress,
|
||||||
@ -333,8 +288,8 @@ export const AuditRepository = {
|
|||||||
batchWriteResults,
|
batchWriteResults,
|
||||||
getAuditForProject,
|
getAuditForProject,
|
||||||
getAuditsByProject,
|
getAuditsByProject,
|
||||||
getAuditResultsForProject,
|
|
||||||
getAuditCapacityUsageForUser,
|
getAuditCapacityUsageForUser,
|
||||||
getPsiResultById,
|
getAuditResultsForProject,
|
||||||
|
getLighthouseResultById,
|
||||||
deleteAuditForProject,
|
deleteAuditForProject,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -1,50 +1,33 @@
|
|||||||
/**
|
|
||||||
* Business logic layer for site audits.
|
|
||||||
* Orchestrates between the workflow trigger, repository, and data formatting.
|
|
||||||
*/
|
|
||||||
import { env } from "cloudflare:workers";
|
import { env } from "cloudflare:workers";
|
||||||
|
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
||||||
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
|
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
|
||||||
import { AuditProgressKV } from "@/server/lib/audit/progress-kv";
|
|
||||||
import { normalizeAndValidateStartUrl } from "@/server/lib/audit/url-policy";
|
|
||||||
import { AppError } from "@/server/lib/errors";
|
|
||||||
import type { AuditConfig, PsiStrategy } from "@/server/lib/audit/types";
|
|
||||||
import { ProjectRepository } from "@/server/features/projects/repositories/ProjectRepository";
|
|
||||||
import {
|
import {
|
||||||
|
MAX_USER_AUDIT_USAGE,
|
||||||
clampAuditMaxPages,
|
clampAuditMaxPages,
|
||||||
getEstimatedAuditCapacity,
|
getEstimatedAuditCapacity,
|
||||||
MAX_USER_AUDIT_USAGE,
|
|
||||||
} from "@/server/features/audit/services/audit-capacity";
|
} from "@/server/features/audit/services/audit-capacity";
|
||||||
import { jsonCodec } from "@/shared/json";
|
import { AppError } from "@/server/lib/errors";
|
||||||
import { z } from "zod";
|
import { AuditProgressKV } from "@/server/lib/audit/progress-kv";
|
||||||
|
import {
|
||||||
const auditConfigSchema = z.object({
|
parseAuditConfig,
|
||||||
maxPages: z.number().int().min(10).max(10_000),
|
type AuditConfig,
|
||||||
psiStrategy: z.enum(["auto", "all", "manual", "none"]),
|
type LighthouseStrategy,
|
||||||
psiApiKey: z.string().optional(),
|
} from "@/server/lib/audit/types";
|
||||||
});
|
import { normalizeAndValidateStartUrl } from "@/server/lib/audit/url-policy";
|
||||||
|
|
||||||
const auditConfigCodec = jsonCodec(auditConfigSchema);
|
|
||||||
|
|
||||||
function parseAuditConfig(configRaw: string | null): AuditConfig | null {
|
|
||||||
if (!configRaw) return null;
|
|
||||||
const result = auditConfigCodec.safeParse(configRaw);
|
|
||||||
return result.success ? result.data : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startAudit(input: {
|
async function startAudit(input: {
|
||||||
actorUserId: string;
|
actorUserId: string;
|
||||||
|
billingCustomer: BillingCustomerContext;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
startUrl: string;
|
startUrl: string;
|
||||||
maxPages?: number;
|
maxPages?: number;
|
||||||
psiStrategy?: PsiStrategy;
|
lighthouseStrategy?: LighthouseStrategy;
|
||||||
psiApiKey?: string;
|
|
||||||
}) {
|
}) {
|
||||||
const maxPages = clampAuditMaxPages(input.maxPages);
|
const maxPages = clampAuditMaxPages(input.maxPages);
|
||||||
const psiStrategy = input.psiStrategy ?? "auto";
|
const lighthouseStrategy = input.lighthouseStrategy ?? "auto";
|
||||||
|
|
||||||
const reservation = getEstimatedAuditCapacity({
|
const reservation = getEstimatedAuditCapacity({
|
||||||
maxPages,
|
maxPages,
|
||||||
psiStrategy,
|
lighthouseStrategy,
|
||||||
});
|
});
|
||||||
|
|
||||||
const currentUsage = await AuditRepository.getAuditCapacityUsageForUser(
|
const currentUsage = await AuditRepository.getAuditCapacityUsageForUser(
|
||||||
@ -56,27 +39,7 @@ async function startAudit(input: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const auditId = crypto.randomUUID();
|
const auditId = crypto.randomUUID();
|
||||||
|
const config: AuditConfig = { maxPages, lighthouseStrategy };
|
||||||
const shouldRunPsi = psiStrategy !== "none";
|
|
||||||
let resolvedPsiApiKey = input.psiApiKey?.trim();
|
|
||||||
|
|
||||||
if (shouldRunPsi && !resolvedPsiApiKey) {
|
|
||||||
resolvedPsiApiKey =
|
|
||||||
(await ProjectRepository.getProjectPsiApiKey(input.projectId)) ??
|
|
||||||
undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldRunPsi && !resolvedPsiApiKey) {
|
|
||||||
throw new Error("PSI API key is not set for this project.");
|
|
||||||
}
|
|
||||||
|
|
||||||
const config: AuditConfig = {
|
|
||||||
maxPages,
|
|
||||||
psiStrategy,
|
|
||||||
// PSI key is used for Google quota/abuse control (non-billing).
|
|
||||||
psiApiKey: resolvedPsiApiKey,
|
|
||||||
};
|
|
||||||
|
|
||||||
const startUrl = await normalizeAndValidateStartUrl(input.startUrl);
|
const startUrl = await normalizeAndValidateStartUrl(input.startUrl);
|
||||||
|
|
||||||
await AuditRepository.createAudit({
|
await AuditRepository.createAudit({
|
||||||
@ -87,15 +50,15 @@ async function startAudit(input: {
|
|||||||
workflowInstanceId: auditId,
|
workflowInstanceId: auditId,
|
||||||
config,
|
config,
|
||||||
pagesTotal: reservation.pagesTotal,
|
pagesTotal: reservation.pagesTotal,
|
||||||
psiTotal: reservation.psiTotal,
|
lighthouseTotal: reservation.lighthouseTotal,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Trigger the Cloudflare Workflow
|
|
||||||
try {
|
try {
|
||||||
await env.SITE_AUDIT_WORKFLOW.create({
|
await env.SITE_AUDIT_WORKFLOW.create({
|
||||||
id: auditId,
|
id: auditId,
|
||||||
params: {
|
params: {
|
||||||
auditId,
|
auditId,
|
||||||
|
billingCustomer: input.billingCustomer,
|
||||||
projectId: input.projectId,
|
projectId: input.projectId,
|
||||||
startUrl,
|
startUrl,
|
||||||
config,
|
config,
|
||||||
@ -108,6 +71,7 @@ async function startAudit(input: {
|
|||||||
} catch {
|
} catch {
|
||||||
// The workflow may never have been created, or may already be gone.
|
// The workflow may never have been created, or may already be gone.
|
||||||
}
|
}
|
||||||
|
|
||||||
await AuditRepository.deleteAuditForProject(auditId, input.projectId);
|
await AuditRepository.deleteAuditForProject(auditId, input.projectId);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@ -125,9 +89,9 @@ async function getStatus(auditId: string, projectId: string) {
|
|||||||
status: audit.status,
|
status: audit.status,
|
||||||
pagesCrawled: audit.pagesCrawled,
|
pagesCrawled: audit.pagesCrawled,
|
||||||
pagesTotal: audit.pagesTotal,
|
pagesTotal: audit.pagesTotal,
|
||||||
psiTotal: audit.psiTotal,
|
lighthouseTotal: audit.lighthouseTotal,
|
||||||
psiCompleted: audit.psiCompleted,
|
lighthouseCompleted: audit.lighthouseCompleted,
|
||||||
psiFailed: audit.psiFailed,
|
lighthouseFailed: audit.lighthouseFailed,
|
||||||
currentPhase: audit.currentPhase,
|
currentPhase: audit.currentPhase,
|
||||||
startedAt: audit.startedAt,
|
startedAt: audit.startedAt,
|
||||||
completedAt: audit.completedAt,
|
completedAt: audit.completedAt,
|
||||||
@ -135,10 +99,8 @@ async function getStatus(auditId: string, projectId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function getResults(auditId: string, projectId: string) {
|
async function getResults(auditId: string, projectId: string) {
|
||||||
const { audit, pages, psi } = await AuditRepository.getAuditResultsForProject(
|
const { audit, pages, lighthouse } =
|
||||||
auditId,
|
await AuditRepository.getAuditResultsForProject(auditId, projectId);
|
||||||
projectId,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!audit) throw new AppError("NOT_FOUND");
|
if (!audit) throw new AppError("NOT_FOUND");
|
||||||
|
|
||||||
@ -146,7 +108,6 @@ async function getResults(auditId: string, projectId: string) {
|
|||||||
if (!parsedConfig) {
|
if (!parsedConfig) {
|
||||||
throw new AppError("INTERNAL_ERROR", "Invalid audit configuration");
|
throw new AppError("INTERNAL_ERROR", "Invalid audit configuration");
|
||||||
}
|
}
|
||||||
const { psiApiKey: _psiApiKey, ...safeConfig } = parsedConfig;
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
audit: {
|
audit: {
|
||||||
@ -157,31 +118,31 @@ async function getResults(auditId: string, projectId: string) {
|
|||||||
pagesTotal: audit.pagesTotal,
|
pagesTotal: audit.pagesTotal,
|
||||||
startedAt: audit.startedAt,
|
startedAt: audit.startedAt,
|
||||||
completedAt: audit.completedAt,
|
completedAt: audit.completedAt,
|
||||||
config: safeConfig,
|
config: parsedConfig,
|
||||||
},
|
},
|
||||||
pages,
|
pages,
|
||||||
psi,
|
lighthouse,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getHistory(projectId: string) {
|
async function getHistory(projectId: string) {
|
||||||
const auditList = await AuditRepository.getAuditsByProject(projectId);
|
const auditList = await AuditRepository.getAuditsByProject(projectId);
|
||||||
|
|
||||||
const didRunPsi = (configRaw: string | null) => {
|
return auditList.map((audit) => {
|
||||||
const parsed = parseAuditConfig(configRaw);
|
const parsedConfig = parseAuditConfig(audit.config);
|
||||||
return parsed?.psiStrategy != null && parsed.psiStrategy !== "none";
|
const ranLighthouse = parsedConfig?.lighthouseStrategy !== "none";
|
||||||
};
|
|
||||||
|
|
||||||
return auditList.map((a) => ({
|
return {
|
||||||
id: a.id,
|
id: audit.id,
|
||||||
startUrl: a.startUrl,
|
startUrl: audit.startUrl,
|
||||||
status: a.status,
|
status: audit.status,
|
||||||
pagesCrawled: a.pagesCrawled,
|
pagesCrawled: audit.pagesCrawled,
|
||||||
pagesTotal: a.pagesTotal,
|
pagesTotal: audit.pagesTotal,
|
||||||
ranPsi: didRunPsi(a.config),
|
ranLighthouse,
|
||||||
startedAt: a.startedAt,
|
startedAt: audit.startedAt,
|
||||||
completedAt: a.completedAt,
|
completedAt: audit.completedAt,
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getCrawlProgress(auditId: string, projectId: string) {
|
async function getCrawlProgress(auditId: string, projectId: string) {
|
||||||
@ -189,6 +150,7 @@ async function getCrawlProgress(auditId: string, projectId: string) {
|
|||||||
if (!audit) {
|
if (!audit) {
|
||||||
throw new AppError("NOT_FOUND");
|
throw new AppError("NOT_FOUND");
|
||||||
}
|
}
|
||||||
|
|
||||||
return AuditProgressKV.getCrawledUrls(auditId);
|
return AuditProgressKV.getCrawledUrls(auditId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -197,6 +159,7 @@ async function remove(auditId: string, projectId: string) {
|
|||||||
if (!audit) {
|
if (!audit) {
|
||||||
throw new AppError("NOT_FOUND");
|
throw new AppError("NOT_FOUND");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (audit.status === "running") {
|
if (audit.status === "running") {
|
||||||
if (!audit.workflowInstanceId) {
|
if (!audit.workflowInstanceId) {
|
||||||
throw new AppError(
|
throw new AppError(
|
||||||
@ -215,6 +178,7 @@ async function remove(auditId: string, projectId: string) {
|
|||||||
throw new AppError("CONFLICT", "Unable to stop the running audit.");
|
throw new AppError("CONFLICT", "Unable to stop the running audit.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await AuditRepository.deleteAuditForProject(auditId, projectId);
|
await AuditRepository.deleteAuditForProject(auditId, projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -13,41 +13,46 @@ describe("audit capacity helpers", () => {
|
|||||||
expect(clampAuditMaxPages(20_000)).toBe(10_000);
|
expect(clampAuditMaxPages(20_000)).toBe(10_000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("estimates capacity for each psi strategy", () => {
|
it("estimates capacity for each lighthouse strategy", () => {
|
||||||
expect(
|
expect(
|
||||||
getEstimatedAuditCapacity({ maxPages: 100, psiStrategy: "none" }),
|
getEstimatedAuditCapacity({ maxPages: 100, lighthouseStrategy: "none" }),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
pagesTotal: 100,
|
pagesTotal: 100,
|
||||||
psiTotal: 0,
|
lighthouseTotal: 0,
|
||||||
total: 100,
|
total: 100,
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
getEstimatedAuditCapacity({ maxPages: 100, psiStrategy: "manual" }),
|
getEstimatedAuditCapacity({
|
||||||
|
maxPages: 100,
|
||||||
|
lighthouseStrategy: "manual",
|
||||||
|
}),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
pagesTotal: 100,
|
pagesTotal: 100,
|
||||||
psiTotal: 0,
|
lighthouseTotal: 0,
|
||||||
total: 100,
|
total: 100,
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
getEstimatedAuditCapacity({ maxPages: 100, psiStrategy: "auto" }),
|
getEstimatedAuditCapacity({ maxPages: 100, lighthouseStrategy: "auto" }),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
pagesTotal: 100,
|
pagesTotal: 100,
|
||||||
psiTotal: 20,
|
lighthouseTotal: 20,
|
||||||
total: 120,
|
total: 120,
|
||||||
});
|
});
|
||||||
expect(
|
expect(
|
||||||
getEstimatedAuditCapacity({ maxPages: 100, psiStrategy: "all" }),
|
getEstimatedAuditCapacity({ maxPages: 100, lighthouseStrategy: "all" }),
|
||||||
).toEqual({
|
).toEqual({
|
||||||
pagesTotal: 100,
|
pagesTotal: 100,
|
||||||
psiTotal: 200,
|
lighthouseTotal: 200,
|
||||||
total: 300,
|
total: 300,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stays within the global capacity limit for the maximum auto audit", () => {
|
it("stays within the global capacity limit for the maximum auto audit", () => {
|
||||||
expect(
|
expect(
|
||||||
getEstimatedAuditCapacity({ maxPages: 10_000, psiStrategy: "auto" })
|
getEstimatedAuditCapacity({
|
||||||
.total,
|
maxPages: 10_000,
|
||||||
|
lighthouseStrategy: "auto",
|
||||||
|
}).total,
|
||||||
).toBeLessThan(MAX_USER_AUDIT_USAGE);
|
).toBeLessThan(MAX_USER_AUDIT_USAGE);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { PsiStrategy } from "@/server/lib/audit/types";
|
import type { LighthouseStrategy } from "@/server/lib/audit/types";
|
||||||
|
|
||||||
export const MAX_USER_AUDIT_USAGE = 100_000;
|
export const MAX_USER_AUDIT_USAGE = 100_000;
|
||||||
|
|
||||||
@ -8,28 +8,28 @@ export function clampAuditMaxPages(maxPages?: number) {
|
|||||||
|
|
||||||
export function getEstimatedAuditCapacity(input: {
|
export function getEstimatedAuditCapacity(input: {
|
||||||
maxPages?: number;
|
maxPages?: number;
|
||||||
psiStrategy?: PsiStrategy;
|
lighthouseStrategy?: LighthouseStrategy;
|
||||||
}) {
|
}) {
|
||||||
const pagesTotal = clampAuditMaxPages(input.maxPages);
|
const pagesTotal = clampAuditMaxPages(input.maxPages);
|
||||||
const psiStrategy = input.psiStrategy ?? "auto";
|
const lighthouseStrategy = input.lighthouseStrategy ?? "auto";
|
||||||
|
|
||||||
let psiTotal = 0;
|
let lighthouseChecks = 0;
|
||||||
switch (psiStrategy) {
|
switch (lighthouseStrategy) {
|
||||||
case "all":
|
case "all":
|
||||||
psiTotal = pagesTotal * 2;
|
lighthouseChecks = pagesTotal * 2;
|
||||||
break;
|
break;
|
||||||
case "auto":
|
case "auto":
|
||||||
psiTotal = 20;
|
lighthouseChecks = 20;
|
||||||
break;
|
break;
|
||||||
case "manual":
|
case "manual":
|
||||||
case "none":
|
case "none":
|
||||||
psiTotal = 0;
|
lighthouseChecks = 0;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
pagesTotal,
|
pagesTotal,
|
||||||
psiTotal,
|
lighthouseTotal: lighthouseChecks,
|
||||||
total: pagesTotal + psiTotal,
|
total: pagesTotal + lighthouseChecks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
async function createProject(
|
||||||
organizationId: string,
|
organizationId: string,
|
||||||
name: string,
|
name: string,
|
||||||
@ -85,9 +63,6 @@ export const ProjectRepository = {
|
|||||||
listProjects,
|
listProjects,
|
||||||
getProjectForOrganization,
|
getProjectForOrganization,
|
||||||
getProjectById,
|
getProjectById,
|
||||||
getProjectPsiApiKey,
|
|
||||||
setProjectPsiApiKey,
|
|
||||||
clearProjectPsiApiKey,
|
|
||||||
createProject,
|
createProject,
|
||||||
deleteProject,
|
deleteProject,
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@ -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.
|
* Shared types for the site audit system.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export type PsiStrategy = "auto" | "all" | "manual" | "none";
|
import { z } from "zod";
|
||||||
|
import { jsonCodec } from "@/shared/json";
|
||||||
|
|
||||||
|
export type LighthouseStrategy = "auto" | "all" | "manual" | "none";
|
||||||
|
|
||||||
export interface AuditConfig {
|
export interface AuditConfig {
|
||||||
maxPages: number;
|
maxPages: number;
|
||||||
psiStrategy: PsiStrategy;
|
lighthouseStrategy: LighthouseStrategy;
|
||||||
psiApiKey?: string;
|
}
|
||||||
|
|
||||||
|
const auditConfigSchema = z.object({
|
||||||
|
maxPages: z.number().int().min(10).max(10_000),
|
||||||
|
lighthouseStrategy: z.enum(["auto", "all", "manual", "none"]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const auditConfigCodec = jsonCodec(auditConfigSchema);
|
||||||
|
|
||||||
|
export function parseAuditConfig(configRaw: string | null): AuditConfig | null {
|
||||||
|
if (!configRaw) return null;
|
||||||
|
const result = auditConfigCodec.safeParse(configRaw);
|
||||||
|
return result.success ? result.data : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Data extracted from a single page via cheerio. */
|
/** Data extracted from a single page via cheerio. */
|
||||||
@ -47,8 +62,8 @@ export interface PageAnalysis {
|
|||||||
hreflangTags: string[];
|
hreflangTags: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** PSI result for a single URL+strategy. */
|
/** Lighthouse result for a single URL+strategy. */
|
||||||
export interface PsiResult {
|
export interface LighthouseResult {
|
||||||
url: string;
|
url: string;
|
||||||
pageId: string;
|
pageId: string;
|
||||||
strategy: "mobile" | "desktop";
|
strategy: "mobile" | "desktop";
|
||||||
@ -63,5 +78,35 @@ export interface PsiResult {
|
|||||||
errorMessage?: string | null;
|
errorMessage?: string | null;
|
||||||
r2Key?: string | null;
|
r2Key?: string | null;
|
||||||
payloadSizeBytes?: number | null;
|
payloadSizeBytes?: number | null;
|
||||||
rawPayloadJson?: string | null;
|
}
|
||||||
|
|
||||||
|
export interface StepPageResult {
|
||||||
|
id: string;
|
||||||
|
url: string;
|
||||||
|
statusCode: number;
|
||||||
|
redirectUrl: string | null;
|
||||||
|
title: string;
|
||||||
|
metaDescription: string;
|
||||||
|
canonicalUrl: string | null;
|
||||||
|
robotsMeta: string | null;
|
||||||
|
ogTitle: string | null;
|
||||||
|
ogDescription: string | null;
|
||||||
|
ogImage: string | null;
|
||||||
|
h1Count: number;
|
||||||
|
h2Count: number;
|
||||||
|
h3Count: number;
|
||||||
|
h4Count: number;
|
||||||
|
h5Count: number;
|
||||||
|
h6Count: number;
|
||||||
|
headingOrder: number[];
|
||||||
|
wordCount: number;
|
||||||
|
imagesTotal: number;
|
||||||
|
imagesMissingAlt: number;
|
||||||
|
images: Array<{ src: string | null; alt: string | null }>;
|
||||||
|
internalLinks: string[];
|
||||||
|
externalLinks: string[];
|
||||||
|
hasStructuredData: boolean;
|
||||||
|
hreflangTags: string[];
|
||||||
|
isIndexable: boolean;
|
||||||
|
responseTimeMs: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,9 @@ import {
|
|||||||
type LabsKeywordDataItem,
|
type LabsKeywordDataItem,
|
||||||
type SerpLiveItem,
|
type SerpLiveItem,
|
||||||
} from "@/server/lib/dataforseo";
|
} from "@/server/lib/dataforseo";
|
||||||
|
import { fetchDataforseoLighthouseResultRaw } from "@/server/lib/dataforseoLighthouse";
|
||||||
|
import type { LighthouseStrategy } from "@/server/lib/dataforseoLighthousePayload";
|
||||||
|
import type { StoredLighthousePayload } from "@/server/lib/lighthouseStoredPayload";
|
||||||
import {
|
import {
|
||||||
fetchBacklinksRowsRaw,
|
fetchBacklinksRowsRaw,
|
||||||
fetchBacklinksSummaryRaw,
|
fetchBacklinksSummaryRaw,
|
||||||
@ -166,6 +169,13 @@ export function createDataforseoClient(customer: BillingCustomerContext) {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
lighthouse: {
|
||||||
|
live(input: { url: string; strategy: LighthouseStrategy }) {
|
||||||
|
return meterDataforseoCall<StoredLighthousePayload>(customer, () =>
|
||||||
|
fetchDataforseoLighthouseResultRaw(input),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
} as const;
|
} as const;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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 WorkflowEvent,
|
||||||
type WorkflowStep,
|
type WorkflowStep,
|
||||||
} from "cloudflare:workers";
|
} from "cloudflare:workers";
|
||||||
|
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
||||||
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
|
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
|
||||||
import type { AuditConfig } from "@/server/lib/audit/types";
|
import type { AuditConfig } from "@/server/lib/audit/types";
|
||||||
import { runAuditPhases } from "@/server/workflows/siteAuditWorkflowPhases";
|
import { runAuditPhases } from "@/server/workflows/siteAuditWorkflowPhases";
|
||||||
|
|
||||||
interface AuditParams {
|
interface AuditParams {
|
||||||
auditId: string;
|
auditId: string;
|
||||||
|
billingCustomer: BillingCustomerContext;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
startUrl: string;
|
startUrl: string;
|
||||||
config: AuditConfig;
|
config: AuditConfig;
|
||||||
@ -22,7 +24,8 @@ interface AuditParams {
|
|||||||
|
|
||||||
export class SiteAuditWorkflow extends WorkflowEntrypoint<Env, AuditParams> {
|
export class SiteAuditWorkflow extends WorkflowEntrypoint<Env, AuditParams> {
|
||||||
async run(event: WorkflowEvent<AuditParams>, step: WorkflowStep) {
|
async run(event: WorkflowEvent<AuditParams>, step: WorkflowStep) {
|
||||||
const { auditId, projectId, startUrl, config } = event.payload;
|
const { auditId, billingCustomer, projectId, startUrl, config } =
|
||||||
|
event.payload;
|
||||||
|
|
||||||
const audit = await AuditRepository.getAuditForWorkflow(
|
const audit = await AuditRepository.getAuditForWorkflow(
|
||||||
auditId,
|
auditId,
|
||||||
@ -41,6 +44,7 @@ export class SiteAuditWorkflow extends WorkflowEntrypoint<Env, AuditParams> {
|
|||||||
await runAuditPhases(step, {
|
await runAuditPhases(step, {
|
||||||
auditId,
|
auditId,
|
||||||
workflowInstanceId: event.instanceId,
|
workflowInstanceId: event.instanceId,
|
||||||
|
billingCustomer,
|
||||||
projectId,
|
projectId,
|
||||||
startUrl,
|
startUrl,
|
||||||
config,
|
config,
|
||||||
|
|||||||
@ -1,64 +1,6 @@
|
|||||||
import { analyzeHtml } from "@/server/lib/audit/page-analyzer";
|
import { analyzeHtml } from "@/server/lib/audit/page-analyzer";
|
||||||
import { fetchPsiResult } from "@/server/lib/audit/psi";
|
import type { StepPageResult } from "@/server/lib/audit/types";
|
||||||
import { isSameOrigin, normalizeUrl } from "@/server/lib/audit/url-utils";
|
import { isSameOrigin, normalizeUrl } from "@/server/lib/audit/url-utils";
|
||||||
import type { PsiResult } from "@/server/lib/audit/types";
|
|
||||||
import { putTextToR2 } from "@/server/lib/r2";
|
|
||||||
|
|
||||||
export interface StepPageResult {
|
|
||||||
id: string;
|
|
||||||
url: string;
|
|
||||||
statusCode: number;
|
|
||||||
redirectUrl: string | null;
|
|
||||||
title: string;
|
|
||||||
metaDescription: string;
|
|
||||||
canonicalUrl: string | null;
|
|
||||||
robotsMeta: string | null;
|
|
||||||
ogTitle: string | null;
|
|
||||||
ogDescription: string | null;
|
|
||||||
ogImage: string | null;
|
|
||||||
h1Count: number;
|
|
||||||
h2Count: number;
|
|
||||||
h3Count: number;
|
|
||||||
h4Count: number;
|
|
||||||
h5Count: number;
|
|
||||||
h6Count: number;
|
|
||||||
headingOrder: number[];
|
|
||||||
wordCount: number;
|
|
||||||
imagesTotal: number;
|
|
||||||
imagesMissingAlt: number;
|
|
||||||
images: Array<{ src: string | null; alt: string | null }>;
|
|
||||||
internalLinks: string[];
|
|
||||||
externalLinks: string[];
|
|
||||||
hasStructuredData: boolean;
|
|
||||||
hreflangTags: string[];
|
|
||||||
isIndexable: boolean;
|
|
||||||
responseTimeMs: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
type PsiUploadContext = {
|
|
||||||
projectId: string;
|
|
||||||
auditId: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function fetchPsiAndUploadToR2(
|
|
||||||
url: string,
|
|
||||||
pageId: string,
|
|
||||||
strategy: "mobile" | "desktop",
|
|
||||||
apiKey: string,
|
|
||||||
context: PsiUploadContext,
|
|
||||||
): Promise<PsiResult> {
|
|
||||||
const result = await fetchPsiResult(url, pageId, strategy, apiKey);
|
|
||||||
|
|
||||||
if (result.rawPayloadJson) {
|
|
||||||
const key = `site-audit/${context.projectId}/${context.auditId}/${pageId}-${strategy}.json`;
|
|
||||||
const uploaded = await putTextToR2(key, result.rawPayloadJson);
|
|
||||||
result.r2Key = uploaded.key;
|
|
||||||
result.payloadSizeBytes = uploaded.sizeBytes;
|
|
||||||
result.rawPayloadJson = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function crawlPage(
|
export async function crawlPage(
|
||||||
url: string,
|
url: string,
|
||||||
|
|||||||
@ -1,12 +1,10 @@
|
|||||||
import type { WorkflowStep } from "cloudflare:workers";
|
import type { WorkflowStep } from "cloudflare:workers";
|
||||||
import type { RobotsResult } from "@/server/lib/audit/discovery";
|
import type { RobotsResult } from "@/server/lib/audit/discovery";
|
||||||
|
import type { StepPageResult } from "@/server/lib/audit/types";
|
||||||
import { isSameOrigin, normalizeUrl } from "@/server/lib/audit/url-utils";
|
import { isSameOrigin, normalizeUrl } from "@/server/lib/audit/url-utils";
|
||||||
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
|
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
|
||||||
import { AuditProgressKV } from "@/server/lib/audit/progress-kv";
|
import { AuditProgressKV } from "@/server/lib/audit/progress-kv";
|
||||||
import {
|
import { crawlPage } from "@/server/workflows/site-audit-workflow-helpers";
|
||||||
crawlPage,
|
|
||||||
type StepPageResult,
|
|
||||||
} from "@/server/workflows/site-audit-workflow-helpers";
|
|
||||||
|
|
||||||
const CRAWL_CONCURRENCY = 25;
|
const CRAWL_CONCURRENCY = 25;
|
||||||
|
|
||||||
|
|||||||
@ -1,19 +1,23 @@
|
|||||||
import type { WorkflowStep } from "cloudflare:workers";
|
import type { WorkflowStep } from "cloudflare:workers";
|
||||||
|
import type { BillingCustomerContext } from "@/server/billing/subscription";
|
||||||
import { discoverUrls, fetchRobotsTxt } from "@/server/lib/audit/discovery";
|
import { discoverUrls, fetchRobotsTxt } from "@/server/lib/audit/discovery";
|
||||||
import { selectPsiSample } from "@/server/lib/audit/psi";
|
import {
|
||||||
|
fetchAndStoreLighthouseResult,
|
||||||
|
selectLighthouseSample,
|
||||||
|
} from "@/server/lib/audit/lighthouse";
|
||||||
import { getOrigin } from "@/server/lib/audit/url-utils";
|
import { getOrigin } from "@/server/lib/audit/url-utils";
|
||||||
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
|
import { AuditRepository } from "@/server/features/audit/repositories/AuditRepository";
|
||||||
import { AuditProgressKV } from "@/server/lib/audit/progress-kv";
|
import { AuditProgressKV } from "@/server/lib/audit/progress-kv";
|
||||||
import type { AuditConfig, PsiResult } from "@/server/lib/audit/types";
|
import type {
|
||||||
import {
|
AuditConfig,
|
||||||
fetchPsiAndUploadToR2,
|
LighthouseResult,
|
||||||
type StepPageResult,
|
StepPageResult,
|
||||||
} from "@/server/workflows/site-audit-workflow-helpers";
|
} from "@/server/lib/audit/types";
|
||||||
import { runCrawlPhase } from "@/server/workflows/siteAuditWorkflowCrawl";
|
import { runCrawlPhase } from "@/server/workflows/siteAuditWorkflowCrawl";
|
||||||
|
|
||||||
const PSI_URL_CONCURRENCY = 6;
|
const LIGHTHOUSE_URL_BATCH_SIZE = 10;
|
||||||
|
|
||||||
function countPsiBatchResults(results: PsiResult[]): {
|
function countLighthouseBatchResults(results: LighthouseResult[]): {
|
||||||
completed: number;
|
completed: number;
|
||||||
failed: number;
|
failed: number;
|
||||||
} {
|
} {
|
||||||
@ -32,6 +36,7 @@ function countPsiBatchResults(results: PsiResult[]): {
|
|||||||
type AuditPhasesParams = {
|
type AuditPhasesParams = {
|
||||||
auditId: string;
|
auditId: string;
|
||||||
workflowInstanceId: string;
|
workflowInstanceId: string;
|
||||||
|
billingCustomer: BillingCustomerContext;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
startUrl: string;
|
startUrl: string;
|
||||||
config: AuditConfig;
|
config: AuditConfig;
|
||||||
@ -41,7 +46,14 @@ export async function runAuditPhases(
|
|||||||
step: WorkflowStep,
|
step: WorkflowStep,
|
||||||
params: AuditPhasesParams,
|
params: AuditPhasesParams,
|
||||||
) {
|
) {
|
||||||
const { auditId, workflowInstanceId, projectId, startUrl, config } = params;
|
const {
|
||||||
|
auditId,
|
||||||
|
workflowInstanceId,
|
||||||
|
billingCustomer,
|
||||||
|
projectId,
|
||||||
|
startUrl,
|
||||||
|
config,
|
||||||
|
} = params;
|
||||||
const origin = getOrigin(startUrl);
|
const origin = getOrigin(startUrl);
|
||||||
const maxPages = config.maxPages;
|
const maxPages = config.maxPages;
|
||||||
|
|
||||||
@ -62,15 +74,22 @@ export async function runAuditPhases(
|
|||||||
robots,
|
robots,
|
||||||
sitemapUrls: discovery.sitemapUrls,
|
sitemapUrls: discovery.sitemapUrls,
|
||||||
});
|
});
|
||||||
const psiResults = await runPsiPhase(step, {
|
const lighthouseResults = await runLighthousePhase(step, {
|
||||||
auditId,
|
auditId,
|
||||||
workflowInstanceId,
|
workflowInstanceId,
|
||||||
|
billingCustomer,
|
||||||
projectId,
|
projectId,
|
||||||
startUrl,
|
startUrl,
|
||||||
config,
|
config,
|
||||||
allPages,
|
allPages,
|
||||||
});
|
});
|
||||||
await finalizeAudit(step, auditId, workflowInstanceId, allPages, psiResults);
|
await finalizeAudit(
|
||||||
|
step,
|
||||||
|
auditId,
|
||||||
|
workflowInstanceId,
|
||||||
|
allPages,
|
||||||
|
lighthouseResults,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runDiscoveryPhase(
|
async function runDiscoveryPhase(
|
||||||
@ -90,115 +109,134 @@ async function runDiscoveryPhase(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
type PsiPhaseParams = {
|
type LighthousePhaseParams = {
|
||||||
auditId: string;
|
auditId: string;
|
||||||
workflowInstanceId: string;
|
workflowInstanceId: string;
|
||||||
|
billingCustomer: BillingCustomerContext;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
startUrl: string;
|
startUrl: string;
|
||||||
config: AuditConfig;
|
config: AuditConfig;
|
||||||
allPages: StepPageResult[];
|
allPages: StepPageResult[];
|
||||||
};
|
};
|
||||||
|
|
||||||
async function runPsiPhase(
|
async function runLighthousePhase(
|
||||||
step: WorkflowStep,
|
step: WorkflowStep,
|
||||||
params: PsiPhaseParams,
|
params: LighthousePhaseParams,
|
||||||
): Promise<PsiResult[]> {
|
): Promise<LighthouseResult[]> {
|
||||||
const { auditId, workflowInstanceId, projectId, startUrl, config, allPages } =
|
const {
|
||||||
params;
|
auditId,
|
||||||
if (config.psiStrategy === "none" || !config.psiApiKey) return [];
|
workflowInstanceId,
|
||||||
|
billingCustomer,
|
||||||
|
projectId,
|
||||||
|
startUrl,
|
||||||
|
config,
|
||||||
|
allPages,
|
||||||
|
} = params;
|
||||||
|
if (config.lighthouseStrategy === "none") return [];
|
||||||
|
|
||||||
const psiSample = await selectPsiUrls({
|
const lighthouseWork = await selectLighthousePages({
|
||||||
step,
|
step,
|
||||||
auditId,
|
auditId,
|
||||||
workflowInstanceId,
|
workflowInstanceId,
|
||||||
allPages,
|
allPages,
|
||||||
startUrl,
|
startUrl,
|
||||||
strategy: config.psiStrategy,
|
strategy: config.lighthouseStrategy,
|
||||||
});
|
|
||||||
const psiWork = psiSample.flatMap((psiUrl) => {
|
|
||||||
const page = allPages.find((candidate) => candidate.url === psiUrl);
|
|
||||||
if (!page) return [];
|
|
||||||
return [{ url: psiUrl, pageId: page.id }];
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const psiResults: PsiResult[] = [];
|
const lighthouseResults: LighthouseResult[] = [];
|
||||||
let psiCompleted = 0;
|
let completedChecks = 0;
|
||||||
let psiFailed = 0;
|
let failedChecks = 0;
|
||||||
let psiBatchIndex = 0;
|
let lighthouseBatchIndex = 0;
|
||||||
|
|
||||||
for (let i = 0; i < psiWork.length; i += PSI_URL_CONCURRENCY) {
|
for (let i = 0; i < lighthouseWork.length; i += LIGHTHOUSE_URL_BATCH_SIZE) {
|
||||||
const batch = psiWork.slice(i, i + PSI_URL_CONCURRENCY);
|
const batch = lighthouseWork.slice(i, i + LIGHTHOUSE_URL_BATCH_SIZE);
|
||||||
psiBatchIndex += 1;
|
lighthouseBatchIndex += 1;
|
||||||
const psiBatchResults = await runPsiBatch({
|
const lighthouseBatchResults = await runLighthouseBatch({
|
||||||
step,
|
step,
|
||||||
psiBatchIndex,
|
lighthouseBatchIndex,
|
||||||
batch,
|
batch,
|
||||||
psiApiKey: config.psiApiKey,
|
billingCustomer,
|
||||||
projectId,
|
projectId,
|
||||||
auditId,
|
auditId,
|
||||||
});
|
});
|
||||||
|
|
||||||
psiResults.push(...psiBatchResults);
|
lighthouseResults.push(...lighthouseBatchResults);
|
||||||
const counts = countPsiBatchResults(psiBatchResults);
|
const counts = countLighthouseBatchResults(lighthouseBatchResults);
|
||||||
psiFailed += counts.failed;
|
failedChecks += counts.failed;
|
||||||
psiCompleted += counts.completed;
|
completedChecks += counts.completed;
|
||||||
await step.do(`psi-progress-batch-${psiBatchIndex}`, async () => {
|
await step.do(
|
||||||
|
`lighthouse-progress-batch-${lighthouseBatchIndex}`,
|
||||||
|
async () => {
|
||||||
await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, {
|
await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, {
|
||||||
psiCompleted,
|
lighthouseCompleted: completedChecks,
|
||||||
psiFailed,
|
lighthouseFailed: failedChecks,
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return psiResults;
|
return lighthouseResults;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectPsiUrls(params: {
|
async function selectLighthousePages(params: {
|
||||||
step: WorkflowStep;
|
step: WorkflowStep;
|
||||||
auditId: string;
|
auditId: string;
|
||||||
workflowInstanceId: string;
|
workflowInstanceId: string;
|
||||||
allPages: StepPageResult[];
|
allPages: StepPageResult[];
|
||||||
startUrl: string;
|
startUrl: string;
|
||||||
strategy: AuditConfig["psiStrategy"];
|
strategy: AuditConfig["lighthouseStrategy"];
|
||||||
}) {
|
}) {
|
||||||
const { step, auditId, workflowInstanceId, allPages, startUrl, strategy } =
|
const { step, auditId, workflowInstanceId, allPages, startUrl, strategy } =
|
||||||
params;
|
params;
|
||||||
return step.do("select-psi-sample", async () => {
|
return step.do("select-lighthouse-sample", async () => {
|
||||||
const pagesForSample = allPages.map((page) => ({
|
const sample = selectLighthouseSample(allPages, startUrl, strategy);
|
||||||
id: page.id,
|
const selectedUrls = new Set(sample);
|
||||||
url: page.url,
|
|
||||||
statusCode: page.statusCode,
|
|
||||||
}));
|
|
||||||
const sample = selectPsiSample(pagesForSample, startUrl, strategy);
|
|
||||||
|
|
||||||
await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, {
|
await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, {
|
||||||
currentPhase: "psi",
|
currentPhase: "lighthouse",
|
||||||
psiTotal: sample.length * 2,
|
lighthouseTotal: sample.length * 2,
|
||||||
psiCompleted: 0,
|
lighthouseCompleted: 0,
|
||||||
psiFailed: 0,
|
lighthouseFailed: 0,
|
||||||
});
|
});
|
||||||
return sample;
|
return allPages.flatMap((page) =>
|
||||||
|
selectedUrls.has(page.url) ? [{ url: page.url, pageId: page.id }] : [],
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runPsiBatch(params: {
|
async function runLighthouseBatch(params: {
|
||||||
step: WorkflowStep;
|
step: WorkflowStep;
|
||||||
psiBatchIndex: number;
|
lighthouseBatchIndex: number;
|
||||||
batch: Array<{ url: string; pageId: string }>;
|
batch: Array<{ url: string; pageId: string }>;
|
||||||
psiApiKey: string;
|
billingCustomer: BillingCustomerContext;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
auditId: string;
|
auditId: string;
|
||||||
}) {
|
}) {
|
||||||
const { step, psiBatchIndex, batch, psiApiKey, projectId, auditId } = params;
|
const {
|
||||||
return step.do(`psi-batch-${psiBatchIndex}`, async () => {
|
step,
|
||||||
|
lighthouseBatchIndex,
|
||||||
|
batch,
|
||||||
|
billingCustomer,
|
||||||
|
projectId,
|
||||||
|
auditId,
|
||||||
|
} = params;
|
||||||
|
return step.do(`lighthouse-batch-${lighthouseBatchIndex}`, async () => {
|
||||||
const perUrlResults = await Promise.all(
|
const perUrlResults = await Promise.all(
|
||||||
batch.map(async ({ url, pageId }) => {
|
batch.map(async ({ url, pageId }) => {
|
||||||
const [mobileResult, desktopResult] = await Promise.all([
|
const [mobileResult, desktopResult] = await Promise.all([
|
||||||
fetchPsiAndUploadToR2(url, pageId, "mobile", psiApiKey, {
|
fetchAndStoreLighthouseResult({
|
||||||
|
url,
|
||||||
|
pageId,
|
||||||
|
strategy: "mobile",
|
||||||
|
billingCustomer,
|
||||||
projectId,
|
projectId,
|
||||||
auditId,
|
auditId,
|
||||||
}),
|
}),
|
||||||
fetchPsiAndUploadToR2(url, pageId, "desktop", psiApiKey, {
|
fetchAndStoreLighthouseResult({
|
||||||
|
url,
|
||||||
|
pageId,
|
||||||
|
strategy: "desktop",
|
||||||
|
billingCustomer,
|
||||||
projectId,
|
projectId,
|
||||||
auditId,
|
auditId,
|
||||||
}),
|
}),
|
||||||
@ -216,13 +254,17 @@ async function finalizeAudit(
|
|||||||
auditId: string,
|
auditId: string,
|
||||||
workflowInstanceId: string,
|
workflowInstanceId: string,
|
||||||
allPages: StepPageResult[],
|
allPages: StepPageResult[],
|
||||||
psiResults: PsiResult[],
|
lighthouseResults: LighthouseResult[],
|
||||||
) {
|
) {
|
||||||
await step.do("finalize", async () => {
|
await step.do("finalize", async () => {
|
||||||
await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, {
|
await AuditRepository.updateAuditProgress(auditId, workflowInstanceId, {
|
||||||
currentPhase: "finalizing",
|
currentPhase: "finalizing",
|
||||||
});
|
});
|
||||||
await AuditRepository.batchWriteResults(auditId, allPages, psiResults);
|
await AuditRepository.batchWriteResults(
|
||||||
|
auditId,
|
||||||
|
allPages,
|
||||||
|
lighthouseResults,
|
||||||
|
);
|
||||||
await AuditRepository.completeAudit(auditId, workflowInstanceId, {
|
await AuditRepository.completeAudit(auditId, workflowInstanceId, {
|
||||||
pagesCrawled: allPages.length,
|
pagesCrawled: allPages.length,
|
||||||
pagesTotal: allPages.length,
|
pagesTotal: allPages.length,
|
||||||
|
|||||||
@ -1,14 +1,14 @@
|
|||||||
import { createServerFn } from "@tanstack/react-start";
|
import { createServerFn } from "@tanstack/react-start";
|
||||||
|
import { AuditService } from "@/server/features/audit/services/AuditService";
|
||||||
import { requireProjectContext } from "@/serverFunctions/middleware";
|
import { requireProjectContext } from "@/serverFunctions/middleware";
|
||||||
import {
|
import {
|
||||||
startAuditSchema,
|
|
||||||
getAuditStatusSchema,
|
|
||||||
getAuditResultsSchema,
|
|
||||||
getAuditHistorySchema,
|
|
||||||
deleteAuditSchema,
|
deleteAuditSchema,
|
||||||
|
getAuditHistorySchema,
|
||||||
|
getAuditResultsSchema,
|
||||||
|
getAuditStatusSchema,
|
||||||
getCrawlProgressSchema,
|
getCrawlProgressSchema,
|
||||||
|
startAuditSchema,
|
||||||
} from "@/types/schemas/audit";
|
} from "@/types/schemas/audit";
|
||||||
import { AuditService } from "@/server/features/audit/services/AuditService";
|
|
||||||
|
|
||||||
export const startAudit = createServerFn({ method: "POST" })
|
export const startAudit = createServerFn({ method: "POST" })
|
||||||
.middleware(requireProjectContext)
|
.middleware(requireProjectContext)
|
||||||
@ -16,11 +16,14 @@ export const startAudit = createServerFn({ method: "POST" })
|
|||||||
.handler(async ({ data, context }) => {
|
.handler(async ({ data, context }) => {
|
||||||
return AuditService.startAudit({
|
return AuditService.startAudit({
|
||||||
actorUserId: context.userId,
|
actorUserId: context.userId,
|
||||||
|
billingCustomer: {
|
||||||
|
organizationId: context.organizationId,
|
||||||
|
userEmail: context.userEmail,
|
||||||
|
},
|
||||||
projectId: context.project.id,
|
projectId: context.project.id,
|
||||||
startUrl: data.startUrl,
|
startUrl: data.startUrl,
|
||||||
maxPages: data.maxPages,
|
maxPages: data.maxPages,
|
||||||
psiStrategy: data.psiStrategy,
|
lighthouseStrategy: data.lighthouseStrategy,
|
||||||
psiApiKey: data.psiApiKey,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -38,9 +41,7 @@ export const getAuditResults = createServerFn({ method: "POST" })
|
|||||||
return AuditService.getResults(data.auditId, context.project.id);
|
return AuditService.getResults(data.auditId, context.project.id);
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getAuditHistory = createServerFn({
|
export const getAuditHistory = createServerFn({ method: "POST" })
|
||||||
method: "POST",
|
|
||||||
})
|
|
||||||
.middleware(requireProjectContext)
|
.middleware(requireProjectContext)
|
||||||
.inputValidator((data: unknown) => getAuditHistorySchema.parse(data))
|
.inputValidator((data: unknown) => getAuditHistorySchema.parse(data))
|
||||||
.handler(async ({ context }) => {
|
.handler(async ({ context }) => {
|
||||||
|
|||||||
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 { createServerFn } from "@tanstack/react-start";
|
||||||
import { ProjectService } from "@/server/features/projects/services/ProjectService";
|
import { ProjectService } from "@/server/features/projects/services/ProjectService";
|
||||||
import {
|
import { requireAuthenticatedContext } from "@/serverFunctions/middleware";
|
||||||
requireAuthenticatedContext,
|
|
||||||
requireProjectContext,
|
|
||||||
} from "@/serverFunctions/middleware";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
export const getOrCreateDefaultProject = createServerFn({ method: "POST" })
|
export const getOrCreateDefaultProject = createServerFn({ method: "POST" })
|
||||||
@ -13,13 +10,13 @@ export const getOrCreateDefaultProject = createServerFn({ method: "POST" })
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const getProjectAccess = createServerFn({ method: "POST" })
|
export const getProjectAccess = createServerFn({ method: "POST" })
|
||||||
.middleware(requireProjectContext)
|
.middleware(requireAuthenticatedContext)
|
||||||
.inputValidator((data: unknown) =>
|
.inputValidator((data: unknown) =>
|
||||||
z.object({ projectId: z.string().min(1) }).parse(data),
|
z.object({ projectId: z.string().min(1) }).parse(data),
|
||||||
)
|
)
|
||||||
.handler(async ({ context }) => {
|
.handler(async ({ data, context }) => {
|
||||||
return ProjectService.getProjectForOrganization(
|
return ProjectService.getProjectForOrganization(
|
||||||
context.organizationId,
|
context.organizationId,
|
||||||
context.project.id,
|
data.projectId,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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),
|
projectId: z.string().min(1),
|
||||||
startUrl: z.string().min(1, "URL is required").max(2048),
|
startUrl: z.string().min(1, "URL is required").max(2048),
|
||||||
maxPages: z.number().int().min(10).max(10_000).optional().default(50),
|
maxPages: z.number().int().min(10).max(10_000).optional().default(50),
|
||||||
psiStrategy: z
|
lighthouseStrategy: z
|
||||||
.enum(["auto", "all", "manual", "none"])
|
.enum(["auto", "all", "manual", "none"])
|
||||||
.optional()
|
.optional()
|
||||||
.default("auto"),
|
.default("auto"),
|
||||||
psiApiKey: z.string().optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
export const getAuditStatusSchema = z.object({
|
export const getAuditStatusSchema = z.object({
|
||||||
|
|||||||
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