From eb0a0c6d6c6a7503936b4ef875e9763aef51b1e9 Mon Sep 17 00:00:00 2001 From: Guilherme Rodrigues Date: Tue, 14 Apr 2026 19:45:12 -0300 Subject: [PATCH] feat(agents): add Web Performance agent with CrUX and PageSpeed Insights Adds a new standalone MCP agent package (`packages/web-perf/`) that monitors website performance using Chrome UX Report field data and PageSpeed Insights lab tests. Includes 7 tools (SITE_ADD, SITE_LIST, SITE_GET, SITE_DELETE, PERF_SNAPSHOT, PERF_REPORT, CRUX_HISTORY), 2 prompts (initial-setup, performance-audit), and native UI with CWV gauges, histogram bars, trend sparklines, and opportunity tables. Registers as a well-known agent template with a one-click setup modal in the home page, agents list, and sidebar. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/web/components/home/agents-list.tsx | 32 +- .../home/web-perf-recruit-modal.tsx | 275 ++++++++++++++ .../web/components/sidebar/agents-section.tsx | 27 ++ apps/mesh/src/web/routes/agents-list.tsx | 19 + bun.lock | 27 +- packages/mesh-sdk/src/lib/constants.ts | 7 + packages/web-perf/package.json | 26 ++ packages/web-perf/server/index.ts | 231 ++++++++++++ .../web-perf/server/lib/agent-instructions.ts | 64 ++++ packages/web-perf/server/lib/crux.ts | 192 ++++++++++ packages/web-perf/server/lib/metrics.ts | 51 +++ packages/web-perf/server/lib/pagespeed.ts | 123 +++++++ packages/web-perf/server/lib/report.ts | 164 +++++++++ packages/web-perf/server/lib/storage.ts | 77 ++++ packages/web-perf/server/lib/types.ts | 168 +++++++++ .../web-perf/server/tools/crux-history.ts | 57 +++ packages/web-perf/server/tools/perf-report.ts | 27 ++ .../web-perf/server/tools/perf-snapshot.ts | 116 ++++++ packages/web-perf/server/tools/site-add.ts | 59 +++ packages/web-perf/server/tools/site-delete.ts | 25 ++ packages/web-perf/server/tools/site-get.ts | 26 ++ packages/web-perf/server/tools/site-list.ts | 28 ++ packages/web-perf/server/ui/dashboard.ts | 156 ++++++++ packages/web-perf/server/ui/shared-styles.ts | 179 +++++++++ packages/web-perf/server/ui/site-detail.ts | 346 ++++++++++++++++++ packages/web-perf/tsconfig.json | 11 + 26 files changed, 2508 insertions(+), 5 deletions(-) create mode 100644 apps/mesh/src/web/components/home/web-perf-recruit-modal.tsx create mode 100644 packages/web-perf/package.json create mode 100644 packages/web-perf/server/index.ts create mode 100644 packages/web-perf/server/lib/agent-instructions.ts create mode 100644 packages/web-perf/server/lib/crux.ts create mode 100644 packages/web-perf/server/lib/metrics.ts create mode 100644 packages/web-perf/server/lib/pagespeed.ts create mode 100644 packages/web-perf/server/lib/report.ts create mode 100644 packages/web-perf/server/lib/storage.ts create mode 100644 packages/web-perf/server/lib/types.ts create mode 100644 packages/web-perf/server/tools/crux-history.ts create mode 100644 packages/web-perf/server/tools/perf-report.ts create mode 100644 packages/web-perf/server/tools/perf-snapshot.ts create mode 100644 packages/web-perf/server/tools/site-add.ts create mode 100644 packages/web-perf/server/tools/site-delete.ts create mode 100644 packages/web-perf/server/tools/site-get.ts create mode 100644 packages/web-perf/server/tools/site-list.ts create mode 100644 packages/web-perf/server/ui/dashboard.ts create mode 100644 packages/web-perf/server/ui/shared-styles.ts create mode 100644 packages/web-perf/server/ui/site-detail.ts create mode 100644 packages/web-perf/tsconfig.json diff --git a/apps/mesh/src/web/components/home/agents-list.tsx b/apps/mesh/src/web/components/home/agents-list.tsx index 882b118caf..22e126ed25 100644 --- a/apps/mesh/src/web/components/home/agents-list.tsx +++ b/apps/mesh/src/web/components/home/agents-list.tsx @@ -29,6 +29,7 @@ import { ChevronRight, Plus, Users03 } from "@untitledui/icons"; import { SiteEditorOnboardingModal } from "@/web/components/home/site-editor-onboarding-modal.tsx"; import { SiteDiagnosticsRecruitModal } from "@/web/components/home/site-diagnostics-recruit-modal.tsx"; import { LeanCanvasRecruitModal } from "@/web/components/home/lean-canvas-recruit-modal.tsx"; +import { WebPerfRecruitModal } from "@/web/components/home/web-perf-recruit-modal.tsx"; import { useCreateVirtualMCP } from "@/web/hooks/use-create-virtual-mcp"; import { useNavigateToAgent } from "@/web/hooks/use-navigate-to-agent"; import { Suspense, useState } from "react"; @@ -159,6 +160,7 @@ function AgentsListContent() { const [siteEditorModalOpen, setSiteEditorModalOpen] = useState(false); const [diagnosticsModalOpen, setDiagnosticsModalOpen] = useState(false); const [leanCanvasModalOpen, setLeanCanvasModalOpen] = useState(false); + const [webPerfModalOpen, setWebPerfModalOpen] = useState(false); const navigateToAgent = useNavigateToAgent(); const siteEditorAgent = WELL_KNOWN_AGENT_TEMPLATES.find( @@ -170,6 +172,9 @@ function AgentsListContent() { const leanCanvasAgent = WELL_KNOWN_AGENT_TEMPLATES.find( (t) => t.id === "lean-canvas", )!; + const webPerfAgent = WELL_KNOWN_AGENT_TEMPLATES.find( + (t) => t.id === "web-perf", + )!; const recentIds = readRecentAgentIds(locator); @@ -209,6 +214,15 @@ function AgentsListContent() { a.title === leanCanvasAgent.title), ); + // Check if Web Performance agent already exists + const existingWebPerf = virtualMcps.find( + (a): a is typeof a & { id: string } => + a.id !== null && + ((a as { metadata?: { type?: string } }).metadata?.type === + webPerfAgent.id || + a.title === webPerfAgent.title), + ); + const hasAgents = agents.length > 0; return ( @@ -238,11 +252,21 @@ function AgentsListContent() { : () => setLeanCanvasModalOpen(true) } /> + navigateToAgent(existingWebPerf.id) + : () => setWebPerfModalOpen(true) + } + /> {agents .filter( (a) => a.id !== existingDiagnostics?.id && - a.id !== existingLeanCanvas?.id, + a.id !== existingLeanCanvas?.id && + a.id !== existingWebPerf?.id, ) .map((agent) => ( + + ); } diff --git a/apps/mesh/src/web/components/home/web-perf-recruit-modal.tsx b/apps/mesh/src/web/components/home/web-perf-recruit-modal.tsx new file mode 100644 index 0000000000..87a150cd7c --- /dev/null +++ b/apps/mesh/src/web/components/home/web-perf-recruit-modal.tsx @@ -0,0 +1,275 @@ +/** + * Web Performance Recruitment Modal + * + * Shown when the user clicks the Web Performance agent on the home page. + * Creates a local HTTP connection to the web-perf MCP server + virtual MCP, + * then navigates to the agent view. + */ + +import { useState } from "react"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from "@deco/ui/components/dialog.tsx"; +import { + Drawer, + DrawerContent, + DrawerHeader, + DrawerTitle, +} from "@deco/ui/components/drawer.tsx"; +import { Button } from "@deco/ui/components/button.tsx"; +import { useIsMobile } from "@deco/ui/hooks/use-mobile.ts"; +import { IntegrationIcon } from "@/web/components/integration-icon.tsx"; +import { + SELF_MCP_ALIAS_ID, + WELL_KNOWN_AGENT_TEMPLATES, + useConnectionActions, + useMCPClient, + useMCPToolCallMutation, + useProjectContext, + useVirtualMCPActions, +} from "@decocms/mesh-sdk"; +import type { CollectionListOutput } from "@decocms/bindings/collections"; +import type { ConnectionEntity } from "@decocms/mesh-sdk"; +import { useNavigateToAgent } from "@/web/hooks/use-navigate-to-agent"; + +interface WebPerfRecruitModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + existingAgent?: { id: string } | null; +} + +const WEB_PERF_URL = "http://localhost:3002/mcp"; + +const WEB_PERF_INSTRUCTIONS = `You are a Web Performance Expert agent. You help users monitor, analyze, and improve the performance of their websites using real-world field data (Chrome UX Report) and lab data (PageSpeed Insights / Lighthouse). + +## Core Web Vitals +- LCP (Largest Contentful Paint): Loading. Good < 2.5s, Poor > 4.0s +- INP (Interaction to Next Paint): Interactivity. Good < 200ms, Poor > 500ms +- CLS (Cumulative Layout Shift): Visual stability. Good < 0.1, Poor > 0.25 +- FCP (First Contentful Paint): Good < 1.8s, Poor > 3.0s +- TTFB (Time to First Byte): Good < 800ms, Poor > 1.8s + +## Workflow +1. When a user mentions a website, use the initial-setup prompt: SITE_ADD → PERF_SNAPSHOT → CRUX_HISTORY → PERF_REPORT. +2. Present CrUX field data as the primary indicator. PageSpeed lab data is for diagnostics. +3. Be specific: name the metric, current value, threshold, and concrete fix. +4. Prioritize Core Web Vitals first, then secondary metrics. + +## Output Style +- Use concrete numbers: "LCP is 3.2s (threshold: 2.5s)" not "LCP is slow" +- Structure recommendations as actionable items for GitHub issues or dev tasks`; + +const CAPABILITIES = [ + "Chrome UX Report (CrUX) real-user field data — 28-day rolling averages", + "PageSpeed Insights lab tests with Lighthouse scores and audits", + "Core Web Vitals monitoring: LCP, INP, CLS, FCP, TTFB", + "25-week CrUX history for trend analysis and sparkline charts", + "Visual dashboards with gauges, histograms, and trend charts", + "Actionable performance reports with prioritized fix recommendations", + "Track multiple sites with file-based snapshots over time", +]; + +function RecruitContent({ + onRecruit, + isRecruiting, +}: { + onRecruit: () => void; + isRecruiting: boolean; +}) { + return ( +
+

+ Add a web performance monitoring agent that uses Chrome UX Report field + data and PageSpeed Insights lab tests to analyze, track, and improve + your website performance. +

+ +
+

Capabilities

+
    + {CAPABILITIES.map((cap) => ( +
  • + + + {cap} +
  • + ))} +
+
+ + +
+ ); +} + +export function WebPerfRecruitModal({ + open, + onOpenChange, + existingAgent, +}: WebPerfRecruitModalProps) { + const isMobile = useIsMobile(); + const { org } = useProjectContext(); + const navigateToAgent = useNavigateToAgent(); + const connectionActions = useConnectionActions(); + const virtualMcpActions = useVirtualMCPActions(); + const client = useMCPClient({ + connectionId: SELF_MCP_ALIAS_ID, + orgId: org.id, + }); + const connectionQuery = useMCPToolCallMutation({ client }); + const [isRecruiting, setIsRecruiting] = useState(false); + + const template = WELL_KNOWN_AGENT_TEMPLATES.find((t) => t.id === "web-perf")!; + + const headerIcon = ( + + ); + + const handleRecruit = async () => { + // If agent already exists, just navigate to it + if (existingAgent) { + onOpenChange(false); + navigateToAgent(existingAgent.id); + return; + } + + setIsRecruiting(true); + try { + // 1. Find or create the HTTP connection to the local web-perf MCP + const existingConnectionResult = await connectionQuery.mutateAsync({ + name: "COLLECTION_CONNECTIONS_LIST", + arguments: { + where: { + field: ["connection_url"], + operator: "eq", + value: WEB_PERF_URL, + }, + limit: 1, + offset: 0, + }, + }); + + let connectionId: string; + const existingConnections = ( + existingConnectionResult as { + structuredContent?: CollectionListOutput; + } + )?.structuredContent?.items; + + const matchingConnection = existingConnections?.find( + (c) => c.connection_url === WEB_PERF_URL, + ); + + if (matchingConnection) { + connectionId = matchingConnection.id; + } else { + const connection = await connectionActions.create.mutateAsync({ + title: "Web Performance", + description: + "Web performance monitoring with CrUX and PageSpeed Insights", + icon: template.icon, + connection_type: "HTTP", + connection_url: WEB_PERF_URL, + app_name: "web-perf", + app_id: "deco/web-perf", + metadata: { + type: "web-perf", + source: "local", + }, + }); + connectionId = connection.id; + } + + // 2. Create a virtual MCP (agent) with the connection attached + const virtualMcp = await virtualMcpActions.create.mutateAsync({ + title: "Web Performance", + description: + "Monitor and optimize website performance with CrUX field data and PageSpeed Insights lab tests", + icon: template.icon, + status: "active", + connections: [ + { + connection_id: connectionId, + selected_tools: null, + selected_resources: null, + selected_prompts: null, + }, + ], + metadata: { + type: "web-perf", + instructions: WEB_PERF_INSTRUCTIONS, + ui: { + pinnedViews: [ + { + connectionId, + toolName: "SITE_LIST", + label: "Dashboard", + icon: null, + }, + ], + layout: { + defaultMainView: { + type: "ext-apps", + id: connectionId, + toolName: "SITE_LIST", + }, + chatDefaultOpen: true, + }, + }, + }, + }); + + // 3. Navigate to the new agent + onOpenChange(false); + navigateToAgent(virtualMcp.id!); + } catch (error) { + console.error("Failed to create Web Performance agent:", error); + } finally { + setIsRecruiting(false); + } + }; + + const title = `Add ${template.title}`; + + return isMobile ? ( + + + +
+ {headerIcon} + {title} +
+
+
+ +
+
+
+ ) : ( + + + +
+ {headerIcon} + {title} +
+
+ +
+
+ ); +} diff --git a/apps/mesh/src/web/components/sidebar/agents-section.tsx b/apps/mesh/src/web/components/sidebar/agents-section.tsx index 2685e20560..4f7e4708a2 100644 --- a/apps/mesh/src/web/components/sidebar/agents-section.tsx +++ b/apps/mesh/src/web/components/sidebar/agents-section.tsx @@ -65,6 +65,7 @@ import { SiteEditorOnboardingModal } from "@/web/components/home/site-editor-onb import { SiteDiagnosticsRecruitModal } from "@/web/components/home/site-diagnostics-recruit-modal.tsx"; import { StudioPackRecruitModal } from "@/web/components/home/studio-pack-recruit-modal.tsx"; import { LeanCanvasRecruitModal } from "@/web/components/home/lean-canvas-recruit-modal.tsx"; +import { WebPerfRecruitModal } from "@/web/components/home/web-perf-recruit-modal.tsx"; import { useAgentBadges } from "@/web/hooks/use-agent-badges"; function AgentListItem({ @@ -295,12 +296,14 @@ function PinAgentPopoverContent({ onOpenDiagnosticsModal, onOpenLeanCanvasModal, onOpenStudioPackModal, + onOpenWebPerfModal, }: { onClose: () => void; onOpenSiteEditorModal: () => void; onOpenDiagnosticsModal: () => void; onOpenLeanCanvasModal: () => void; onOpenStudioPackModal: () => void; + onOpenWebPerfModal: () => void; }) { const [search, setSearch] = useState(""); const allAgents = useVirtualMCPs(); @@ -350,6 +353,18 @@ function PinAgentPopoverContent({ ) : undefined; + // Find existing recruited Web Performance agent + const webPerfTemplate = WELL_KNOWN_AGENT_TEMPLATES.find( + (t) => t.id === "web-perf", + ); + const existingWebPerf = webPerfTemplate + ? allAgents.find( + (a) => + (a as { metadata?: { type?: string } }).metadata?.type === + webPerfTemplate.id, + ) + : undefined; + const handleSelect = (agent: VirtualMCPEntity) => { if (!isPinned(agent.id)) { pin(agent.id); @@ -378,6 +393,12 @@ function PinAgentPopoverContent({ } } else if (templateId === "studio-pack") { onOpenStudioPackModal(); + } else if (templateId === "web-perf") { + if (existingWebPerf) { + navigateToAgent(existingWebPerf.id); + } else { + onOpenWebPerfModal(); + } } else { navigateToNewTask(templateId); } @@ -489,6 +510,7 @@ function PinAgentPopover() { const [diagnosticsModalOpen, setDiagnosticsModalOpen] = useState(false); const [leanCanvasModalOpen, setLeanCanvasModalOpen] = useState(false); const [studioPackModalOpen, setStudioPackModalOpen] = useState(false); + const [webPerfModalOpen, setWebPerfModalOpen] = useState(false); const isMobile = useIsMobile(); const { setOpenMobile } = useSidebar(); @@ -511,6 +533,7 @@ function PinAgentPopover() { onOpenDiagnosticsModal={() => setDiagnosticsModalOpen(true)} onOpenLeanCanvasModal={() => setLeanCanvasModalOpen(true)} onOpenStudioPackModal={() => setStudioPackModalOpen(true)} + onOpenWebPerfModal={() => setWebPerfModalOpen(true)} /> ); @@ -572,6 +595,10 @@ function PinAgentPopover() { open={studioPackModalOpen} onOpenChange={setStudioPackModalOpen} /> + ); } diff --git a/apps/mesh/src/web/routes/agents-list.tsx b/apps/mesh/src/web/routes/agents-list.tsx index bdc747682b..54fe0ef3d1 100644 --- a/apps/mesh/src/web/routes/agents-list.tsx +++ b/apps/mesh/src/web/routes/agents-list.tsx @@ -16,6 +16,7 @@ import { SiteEditorOnboardingModal } from "@/web/components/home/site-editor-onb import { SiteDiagnosticsRecruitModal } from "@/web/components/home/site-diagnostics-recruit-modal.tsx"; import { StudioPackRecruitModal } from "@/web/components/home/studio-pack-recruit-modal.tsx"; import { LeanCanvasRecruitModal } from "@/web/components/home/lean-canvas-recruit-modal.tsx"; +import { WebPerfRecruitModal } from "@/web/components/home/web-perf-recruit-modal.tsx"; import { AlertDialog, AlertDialogAction, @@ -49,6 +50,7 @@ export default function AgentsListPage() { const [diagnosticsModalOpen, setDiagnosticsModalOpen] = useState(false); const [studioPackModalOpen, setStudioPackModalOpen] = useState(false); const [leanCanvasModalOpen, setLeanCanvasModalOpen] = useState(false); + const [webPerfModalOpen, setWebPerfModalOpen] = useState(false); const lowerSearch = search.toLowerCase(); @@ -84,6 +86,12 @@ export default function AgentsListPage() { (a as { metadata?: { type?: string } }).metadata?.type === "lean-canvas", ); + // Find existing recruited Web Performance agent + const existingWebPerf = agents.find( + (a) => + (a as { metadata?: { type?: string } }).metadata?.type === "web-perf", + ); + const handleTemplateClick = (templateId: string) => { if (templateId === "site-editor") { setSiteEditorModalOpen(true); @@ -101,6 +109,12 @@ export default function AgentsListPage() { } } else if (templateId === "studio-pack") { setStudioPackModalOpen(true); + } else if (templateId === "web-perf") { + if (existingWebPerf) { + navigateToAgent(existingWebPerf.id); + } else { + setWebPerfModalOpen(true); + } } }; @@ -254,6 +268,11 @@ export default function AgentsListPage() { open={studioPackModalOpen} onOpenChange={setStudioPackModalOpen} /> + =16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], @@ -3276,7 +3291,9 @@ "@daveyplate/better-auth-ui/@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], - "@decocms/runtime/@types/bun": ["@types/bun@1.3.11", "", { "dependencies": { "bun-types": "1.3.11" } }, "sha512-5vPne5QvtpjGpsGYXiFyycfpDF2ECyPcTSsFBMa0fraoxiQyMJ3SmuQIGhzPg2WJuWxVBoxWJ2kClYTcw/4fAg=="], + "@decocms/runtime/@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], + + "@decocms/web-perf/@types/bun": ["@types/bun@1.3.12", "", { "dependencies": { "bun-types": "1.3.12" } }, "sha512-DBv81elK+/VSwXHDlnH3Qduw+KxkTIWi7TXkAeh24zpi5l0B2kUg9Ga3tb4nJaPcOFswflgi/yAvMVBPrxMB+A=="], "@grpc/proto-loader/protobufjs": ["protobufjs@7.5.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.4", "@protobufjs/eventemitter": "^1.1.0", "@protobufjs/fetch": "^1.1.0", "@protobufjs/float": "^1.0.2", "@protobufjs/inquire": "^1.1.0", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.0", "@types/node": ">=13.7.0", "long": "^5.0.0" } }, "sha512-CvexbZtbov6jW2eXAvLukXjXUW1TzFaivC46BpWc/3BpcCysb5Vffu+B3XHMm8lVEuy2Mm4XGex8hBSg1yapPg=="], @@ -3684,7 +3701,9 @@ "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], - "@decocms/runtime/@types/bun/bun-types": ["bun-types@1.3.11", "", { "dependencies": { "@types/node": "*" } }, "sha512-1KGPpoxQWl9f6wcZh57LvrPIInQMn2TQ7jsgxqpRzg+l0QPOFvJVH7HmvHo/AiPgwXy+/Thf6Ov3EdVn1vOabg=="], + "@decocms/runtime/@types/bun/bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], + + "@decocms/web-perf/@types/bun/bun-types": ["bun-types@1.3.12", "", { "dependencies": { "@types/node": "*" } }, "sha512-HqOLj5PoFajAQciOMRiIZGNoKxDJSr6qigAttOX40vJuSp6DN/CxWp9s3C1Xwm4oH7ybueITwiaOcWXoYVoRkA=="], "@oclif/core/ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], diff --git a/packages/mesh-sdk/src/lib/constants.ts b/packages/mesh-sdk/src/lib/constants.ts index b4a46673a3..2e18e87ac7 100644 --- a/packages/mesh-sdk/src/lib/constants.ts +++ b/packages/mesh-sdk/src/lib/constants.ts @@ -327,6 +327,13 @@ export const WELL_KNOWN_AGENT_TEMPLATES = [ icon: "icon://FileCheck02?color=green", type: "registry-agent" as const, }, + { + id: "web-perf", + appId: "deco/web-perf", + title: "Web Performance", + icon: "icon://SpeedoMeter?color=orange", + type: "registry-agent" as const, + }, { id: "studio-pack", title: "Studio Pack", diff --git a/packages/web-perf/package.json b/packages/web-perf/package.json new file mode 100644 index 0000000000..1f1adf5b7e --- /dev/null +++ b/packages/web-perf/package.json @@ -0,0 +1,26 @@ +{ + "name": "@decocms/web-perf", + "version": "0.1.0", + "type": "module", + "description": "Web performance monitoring agent with CrUX and PageSpeed Insights", + "scripts": { + "dev": "bun run --hot server/index.ts", + "start": "bun run server/index.ts", + "check": "tsc --noEmit" + }, + "dependencies": { + "@decocms/runtime": "workspace:*", + "@modelcontextprotocol/sdk": "1.27.1", + "zod": "^4.0.0" + }, + "devDependencies": { + "@types/bun": "latest", + "typescript": "^5.9.3" + }, + "exports": { + ".": "./server/index.ts" + }, + "engines": { + "node": ">=24.0.0" + } +} diff --git a/packages/web-perf/server/index.ts b/packages/web-perf/server/index.ts new file mode 100644 index 0000000000..506297937b --- /dev/null +++ b/packages/web-perf/server/index.ts @@ -0,0 +1,231 @@ +import { withRuntime } from "@decocms/runtime"; +import { createPublicPrompt, createPublicResource } from "@decocms/runtime"; +import { z } from "zod"; +import { SITE_ADD } from "./tools/site-add.ts"; +import { SITE_LIST } from "./tools/site-list.ts"; +import { SITE_GET } from "./tools/site-get.ts"; +import { SITE_DELETE } from "./tools/site-delete.ts"; +import { PERF_SNAPSHOT } from "./tools/perf-snapshot.ts"; +import { PERF_REPORT } from "./tools/perf-report.ts"; +import { CRUX_HISTORY } from "./tools/crux-history.ts"; +import { AGENT_INSTRUCTIONS } from "./lib/agent-instructions.ts"; +import { renderDashboard } from "./ui/dashboard.ts"; +import { renderSiteDetail } from "./ui/site-detail.ts"; +import { listSites, loadSite } from "./lib/storage.ts"; + +const RESOURCE_MIME = "text/html;profile=mcp-app"; +const port = Number(process.env.PORT) || 3002; +const API_ORIGIN = `http://localhost:${port}`; + +const resourceCsp = { + connectDomains: [API_ORIGIN], +}; + +// ── Prompts ── + +const initialSetupPrompt = createPublicPrompt({ + name: "initial-setup", + title: "Web Performance Setup", + description: + "Add a website, collect performance data, and generate a comprehensive initial report", + argsSchema: { + url: z + .string() + .describe("The website URL to track (e.g., https://example.com)"), + name: z.string().optional().describe("A friendly name for the site"), + apiKey: z + .string() + .optional() + .describe("Google API key for CrUX and PageSpeed APIs"), + }, + execute: async ({ args }: { args: Record }) => ({ + messages: [ + { + role: "user" as const, + content: { + type: "text" as const, + text: `I want to set up web performance monitoring for ${args.url}${args.name ? ` (${args.name})` : ""}. + +Please do the following steps in order: +1. Add the site using SITE_ADD with origin "${args.url}"${args.name ? ` and name "${args.name}"` : ""}${args.apiKey ? ` and apiKey "${args.apiKey}"` : ""}. +2. Take a performance snapshot using PERF_SNAPSHOT for the new site${args.apiKey ? ` with apiKey "${args.apiKey}"` : ""}. +3. Fetch CrUX history data using CRUX_HISTORY for the new site${args.apiKey ? ` with apiKey "${args.apiKey}"` : ""}. +4. Generate a performance report using PERF_REPORT for the new site. + +After completing all steps, provide a summary that includes: +- Overall performance rating and score +- Core Web Vitals status (LCP, INP, CLS) with ratings +- Top 3 performance opportunities with estimated savings +- Whether the site passes the Core Web Vitals assessment +- A brief trend analysis if CrUX history data is available`, + }, + }, + ], + }), +}); + +const performanceAuditPrompt = createPublicPrompt({ + name: "performance-audit", + title: "Deep Performance Audit", + description: + "Comprehensive performance analysis with actionable fixes and prioritized recommendations", + argsSchema: { + siteId: z.string().describe("The site ID to audit"), + }, + execute: async ({ args }: { args: Record }) => ({ + messages: [ + { + role: "user" as const, + content: { + type: "text" as const, + text: `Perform a deep performance audit for site ${args.siteId}. + +Steps: +1. Get the full site data using SITE_GET for site "${args.siteId}". +2. If the latest snapshot is older than 24 hours, take a fresh snapshot with PERF_SNAPSHOT. +3. Generate a performance report using PERF_REPORT. + +Then produce a detailed audit report with these sections: + +## Core Web Vitals Assessment +For each CWV (LCP, INP, CLS): current p75, rating, trend direction (improving/stable/degrading), and what the metric means for users. + +## Priority Fixes +For each issue: +- Priority: CRITICAL / HIGH / MEDIUM / LOW +- Metric impacted and estimated improvement +- Specific technical fix (code-level where possible) +- Implementation complexity (easy/medium/hard) + +## Quick Wins (< 1 hour effort) +Easy fixes with high impact. + +## Architecture Recommendations +Longer-term improvements for sustained performance. + +## Actionable Next Steps +Numbered list of concrete actions ordered by impact. Format each so it could be sent as a GitHub issue or forwarded to a site editor agent.`, + }, + }, + ], + }), +}); + +// ── Resources ── + +const dashboardResource = createPublicResource({ + uri: "ui://web-perf/dashboard", + name: "Web Performance Dashboard", + description: "Overview of all tracked sites with performance scores", + mimeType: RESOURCE_MIME, + read: () => ({ + uri: "ui://web-perf/dashboard", + mimeType: RESOURCE_MIME, + text: renderDashboard(), + _meta: { ui: { csp: resourceCsp } }, + }), +}); + +const siteDetailResource = createPublicResource({ + uri: "ui://web-perf/site-detail", + name: "Site Performance Detail", + description: + "Detailed performance view with CWV gauges, histograms, trend charts, and opportunities", + mimeType: RESOURCE_MIME, + read: () => ({ + uri: "ui://web-perf/site-detail", + mimeType: RESOURCE_MIME, + text: renderSiteDetail(API_ORIGIN), + _meta: { ui: { csp: resourceCsp } }, + }), +}); + +// ── MCP Server ── + +const mcpServer = withRuntime({ + serverInfo: { + name: "web-perf", + version: "0.1.0", + instructions: AGENT_INSTRUCTIONS, + }, + tools: [ + SITE_ADD, + SITE_LIST, + SITE_GET, + SITE_DELETE, + PERF_SNAPSHOT, + PERF_REPORT, + CRUX_HISTORY, + ], + prompts: [initialSetupPrompt, performanceAuditPrompt], + resources: [dashboardResource, siteDetailResource], + cors: { + origin: "*", + }, +}); + +// ── REST API for UI iframe polling ── + +function handleApi(req: Request): Response | null { + const url = new URL(req.url); + + if (req.method === "OPTIONS") { + return new Response(null, { + headers: { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", + }, + }); + } + + const corsHeaders = { + "Access-Control-Allow-Origin": "*", + "Content-Type": "application/json", + }; + + // GET /api/sites — list all sites (trimmed) + if (url.pathname === "/api/sites" && req.method === "GET") { + return (async () => { + const sites = await listSites(); + const trimmed = sites.map((s) => ({ + ...s, + snapshots: s.snapshots.slice(0, 1), + })); + return new Response(JSON.stringify({ sites: trimmed }), { + headers: corsHeaders, + }); + })() as unknown as Response; + } + + // GET /api/sites/:id — single site + const siteMatch = url.pathname.match(/^\/api\/sites\/([^/]+)$/); + if (siteMatch && req.method === "GET") { + return (async () => { + const site = await loadSite(siteMatch[1]); + if (!site) { + return new Response(JSON.stringify({ error: "Not found" }), { + status: 404, + headers: corsHeaders, + }); + } + return new Response(JSON.stringify({ site }), { + headers: corsHeaders, + }); + })() as unknown as Response; + } + + return null; +} + +export default { + fetch: async (req: Request, env?: unknown, ctx?: unknown) => { + const apiResponse = handleApi(req); + if (apiResponse) return apiResponse; + return (mcpServer.fetch as Function)(req, env, ctx); + }, + port, +}; + +console.log(`[web-perf] MCP server running on http://localhost:${port}/mcp`); +console.log(`[web-perf] REST API at http://localhost:${port}/api/sites`); diff --git a/packages/web-perf/server/lib/agent-instructions.ts b/packages/web-perf/server/lib/agent-instructions.ts new file mode 100644 index 0000000000..c9dab2d1c8 --- /dev/null +++ b/packages/web-perf/server/lib/agent-instructions.ts @@ -0,0 +1,64 @@ +export const AGENT_INSTRUCTIONS = `You are a Web Performance Expert agent. You help users monitor, analyze, and improve the performance of their websites using real-world field data (Chrome UX Report) and lab data (PageSpeed Insights / Lighthouse). + +## Your Capabilities +- Track multiple websites and collect performance snapshots over time +- Fetch Chrome UX Report (CrUX) field data showing real user experiences (28-day rolling average) +- Fetch CrUX history for trend analysis (25 weekly data points) +- Run PageSpeed Insights lab tests for detailed audit data +- Generate actionable performance reports with prioritized fix recommendations +- Show visual dashboards with Core Web Vitals gauges, histograms, and trend charts + +## Core Web Vitals Context +The three Core Web Vitals are: +- **LCP** (Largest Contentful Paint): Loading performance. Good < 2.5s, Poor > 4.0s +- **INP** (Interaction to Next Paint): Interactivity. Good < 200ms, Poor > 500ms +- **CLS** (Cumulative Layout Shift): Visual stability. Good < 0.1, Poor > 0.25 + +Additional metrics tracked: +- **FCP** (First Contentful Paint): Good < 1.8s, Poor > 3.0s +- **TTFB** (Time to First Byte): Good < 800ms, Poor > 1.8s + +A site **passes** Core Web Vitals if LCP, INP, and CLS are all in the "good" range at the 75th percentile. + +## Workflow Guidelines +1. When a user first mentions a website, use the **initial-setup** prompt flow: add the site, snapshot, fetch history, and report. +2. Always present **CrUX field data as the primary indicator** (real users). PageSpeed lab data provides diagnostic detail. +3. When reporting issues, be specific: name the metric, state the current value, the threshold it violates, and a concrete fix. +4. Prioritize fixes by impact: focus on Core Web Vitals first, then secondary metrics. +5. For trend analysis, note whether metrics are improving, stable, or degrading over the 25-week history. + +## API Key Handling +Users need a Google API key for CrUX and PageSpeed APIs. The key can be: +1. Passed per-site when adding a site (stored in site config) +2. Passed per-request as a tool parameter (takes precedence) +If no key is available, instruct the user to get one from the Google Cloud Console (APIs & Services > Credentials) with the **CrUX API** and **PageSpeed Insights API** enabled. + +## Output Style +- Use concrete numbers: "LCP is 3.2s, which exceeds the 2.5s good threshold" not "LCP is slow" +- When showing results with UI resources, let the visual dashboard complement your text analysis +- Structure recommendations as actionable items that can be turned into development tasks or GitHub issues +- When asked to create issues, format the fix as a clear title + description with reproduction steps, metric impact, and suggested implementation + +## Fix Recommendations Cheat Sheet + +### LCP (Loading) +- Optimize/compress images, use next-gen formats (WebP/AVIF) +- Preload the LCP resource (hero image, key font) +- Reduce server response time (TTFB) +- Remove render-blocking CSS/JS +- Use a CDN for static assets + +### INP (Interactivity) +- Break up long JavaScript tasks (> 50ms) +- Defer non-critical JavaScript +- Optimize event handlers, debounce where appropriate +- Use web workers for heavy computation +- Reduce DOM size + +### CLS (Visual Stability) +- Set explicit width/height on images and videos +- Reserve space for ads and embeds +- Avoid inserting content above existing content +- Use CSS contain for dynamic elements +- Preload web fonts with font-display: swap +`; diff --git a/packages/web-perf/server/lib/crux.ts b/packages/web-perf/server/lib/crux.ts new file mode 100644 index 0000000000..bdb0865130 --- /dev/null +++ b/packages/web-perf/server/lib/crux.ts @@ -0,0 +1,192 @@ +import type { + CrUXRecord, + CrUXData, + CrUXMetric, + CrUXHistoryData, + CrUXHistoryRecord, + CrUXHistoryMetric, +} from "./types.ts"; + +const CRUX_API_BASE = "https://chromeuxreport.googleapis.com/v1/records"; + +type FormFactor = "PHONE" | "DESKTOP" | "ALL_FORM_FACTORS"; + +/** Maps CrUX API metric keys to our short names */ +const METRIC_MAP: Record = { + largest_contentful_paint: "lcp", + interaction_to_next_paint: "inp", + cumulative_layout_shift: "cls", + first_contentful_paint: "fcp", + experimental_time_to_first_byte: "ttfb", +}; + +function toNumber(v: unknown): number { + return typeof v === "string" ? Number.parseFloat(v) : (v as number); +} + +function mapMetrics(apiMetrics: Record): CrUXRecord { + const record: CrUXRecord = {}; + for (const [apiKey, shortKey] of Object.entries(METRIC_MAP)) { + const m = apiMetrics[apiKey] as + | { + histogram: Array<{ + start: unknown; + end?: unknown; + density: unknown; + }>; + percentiles: { p75: unknown }; + } + | undefined; + if (m) { + // Ensure all values are numbers (CLS comes as strings from the API) + record[shortKey] = { + histogram: m.histogram.map((h) => ({ + start: toNumber(h.start), + end: h.end !== undefined ? toNumber(h.end) : undefined, + density: toNumber(h.density), + })), + percentiles: { p75: toNumber(m.percentiles.p75) }, + }; + } + } + return record; +} + +function mapHistoryMetrics( + apiMetrics: Record, +): CrUXHistoryRecord { + const record: CrUXHistoryRecord = {}; + for (const [apiKey, shortKey] of Object.entries(METRIC_MAP)) { + const m = apiMetrics[apiKey] as CrUXHistoryMetric | undefined; + if (m) { + (record as Record)[shortKey] = m; + } + } + return record; +} + +function formatDate(d: { year: number; month: number; day: number }): string { + return `${d.year}-${String(d.month).padStart(2, "0")}-${String(d.day).padStart(2, "0")}`; +} + +export async function fetchCrUXRecord( + origin: string, + apiKey: string, + formFactor?: FormFactor, +): Promise { + const body: Record = { origin }; + if (formFactor && formFactor !== "ALL_FORM_FACTORS") { + body.formFactor = formFactor; + } + + const res = await fetch(`${CRUX_API_BASE}:queryRecord?key=${apiKey}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (res.status === 404) return null; + if (!res.ok) { + const err = await res.text(); + throw new Error(`CrUX API error (${res.status}): ${err}`); + } + + const data = (await res.json()) as { + record: { + metrics: Record; + collectionPeriod?: { + firstDate: { year: number; month: number; day: number }; + lastDate: { year: number; month: number; day: number }; + }; + }; + }; + const record = mapMetrics(data.record.metrics); + // Attach raw collection period for extraction by fetchCrUXData + if (data.record.collectionPeriod) { + (record as Record)._collectionPeriod = + data.record.collectionPeriod; + } + return record; +} + +export async function fetchCrUXData( + origin: string, + apiKey: string, +): Promise { + const [phone, desktop, all] = await Promise.all([ + fetchCrUXRecord(origin, apiKey, "PHONE"), + fetchCrUXRecord(origin, apiKey, "DESKTOP"), + fetchCrUXRecord(origin, apiKey), + ]); + + // Extract collection period from the first successful response + let collectionPeriod = { firstDate: "", lastDate: "" }; + const firstRecord = (all ?? phone ?? desktop) as + | (CrUXRecord & { + _collectionPeriod?: { + firstDate: { year: number; month: number; day: number }; + lastDate: { year: number; month: number; day: number }; + }; + }) + | null; + if (firstRecord?._collectionPeriod) { + const cp = firstRecord._collectionPeriod; + collectionPeriod = { + firstDate: formatDate(cp.firstDate), + lastDate: formatDate(cp.lastDate), + }; + delete (firstRecord as Record)._collectionPeriod; + } + // Clean up _collectionPeriod from all records + for (const r of [phone, desktop, all]) { + if (r) delete (r as Record)._collectionPeriod; + } + + return { + phone: phone ?? undefined, + desktop: desktop ?? undefined, + all: all ?? undefined, + collectionPeriod, + }; +} + +export async function fetchCrUXHistory( + origin: string, + apiKey: string, + formFactor?: FormFactor, +): Promise { + const body: Record = { origin }; + if (formFactor && formFactor !== "ALL_FORM_FACTORS") { + body.formFactor = formFactor; + } + + const res = await fetch(`${CRUX_API_BASE}:queryHistoryRecord?key=${apiKey}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const err = await res.text(); + throw new Error(`CrUX History API error (${res.status}): ${err}`); + } + + const data = (await res.json()) as { + record: { + metrics: Record; + collectionPeriods: Array<{ + firstDate: { year: number; month: number; day: number }; + lastDate: { year: number; month: number; day: number }; + }>; + }; + }; + + return { + record: mapHistoryMetrics(data.record.metrics), + collectionPeriods: data.record.collectionPeriods.map((cp) => ({ + firstDate: formatDate(cp.firstDate), + lastDate: formatDate(cp.lastDate), + })), + fetchedAt: new Date().toISOString(), + }; +} diff --git a/packages/web-perf/server/lib/metrics.ts b/packages/web-perf/server/lib/metrics.ts new file mode 100644 index 0000000000..0d8cef0904 --- /dev/null +++ b/packages/web-perf/server/lib/metrics.ts @@ -0,0 +1,51 @@ +import type { Rating } from "./types.ts"; + +export const CWV_THRESHOLDS = { + lcp: { + good: 2500, + poor: 4000, + unit: "ms", + label: "Largest Contentful Paint", + }, + inp: { good: 200, poor: 500, unit: "ms", label: "Interaction to Next Paint" }, + cls: { good: 0.1, poor: 0.25, unit: "", label: "Cumulative Layout Shift" }, + fcp: { good: 1800, poor: 3000, unit: "ms", label: "First Contentful Paint" }, + ttfb: { good: 800, poor: 1800, unit: "ms", label: "Time to First Byte" }, +} as const; + +export type MetricName = keyof typeof CWV_THRESHOLDS; + +export function rateMetric(name: MetricName, value: number): Rating { + const t = CWV_THRESHOLDS[name]; + if (value <= t.good) return "good"; + if (value <= t.poor) return "needs-improvement"; + return "poor"; +} + +export function ratePerformanceScore(score: number): Rating { + if (score >= 90) return "good"; + if (score >= 50) return "needs-improvement"; + return "poor"; +} + +export function formatMetricValue(name: MetricName, value: number): string { + if (name === "cls") return value.toFixed(2); + if (value >= 1000) return `${(value / 1000).toFixed(1)}s`; + return `${Math.round(value)}ms`; +} + +export const RATING_COLORS = { + good: "#0cce6b", + "needs-improvement": "#ffa400", + poor: "#ff4e42", +} as const; + +/** Core Web Vitals are LCP, INP, CLS — a site "passes" if all three are good */ +export function passesCWV(lcp?: number, inp?: number, cls?: number): boolean { + if (lcp === undefined || inp === undefined || cls === undefined) return false; + return ( + rateMetric("lcp", lcp) === "good" && + rateMetric("inp", inp) === "good" && + rateMetric("cls", cls) === "good" + ); +} diff --git a/packages/web-perf/server/lib/pagespeed.ts b/packages/web-perf/server/lib/pagespeed.ts new file mode 100644 index 0000000000..b4aa2a4bb7 --- /dev/null +++ b/packages/web-perf/server/lib/pagespeed.ts @@ -0,0 +1,123 @@ +import type { PageSpeedData, PageSpeedAudit } from "./types.ts"; + +const PSI_API = + "https://pagespeedonline.googleapis.com/pagespeedonline/v5/runPagespeed"; + +function extractAudit( + audits: Record, + id: string, +): PageSpeedAudit | null { + const a = audits[id] as + | { + id: string; + title: string; + description: string; + score: number | null; + displayValue?: string; + numericValue?: number; + numericUnit?: string; + details?: { + type: string; + overallSavingsMs?: number; + overallSavingsBytes?: number; + items?: Array>; + }; + } + | undefined; + if (!a) return null; + return { + id: a.id, + title: a.title, + description: a.description, + score: a.score, + displayValue: a.displayValue, + numericValue: a.numericValue, + numericUnit: a.numericUnit, + details: a.details, + }; +} + +function getNumericValue(audits: Record, id: string): number { + const a = audits[id] as { numericValue?: number } | undefined; + return a?.numericValue ?? 0; +} + +export async function fetchPageSpeed( + url: string, + apiKey: string, + strategy: "mobile" | "desktop" = "mobile", +): Promise { + const params = new URLSearchParams({ + url, + key: apiKey, + strategy, + category: "performance", + }); + + const res = await fetch(`${PSI_API}?${params}`); + if (!res.ok) { + const err = await res.text(); + throw new Error(`PageSpeed API error (${res.status}): ${err}`); + } + + const data = (await res.json()) as { + lighthouseResult: { + categories: { + performance: { score: number }; + }; + audits: Record; + }; + }; + + const { audits, categories } = data.lighthouseResult; + const performanceScore = Math.round( + (categories.performance.score ?? 0) * 100, + ); + + const metrics = { + fcp: getNumericValue(audits, "first-contentful-paint"), + lcp: getNumericValue(audits, "largest-contentful-paint"), + cls: getNumericValue(audits, "cumulative-layout-shift"), + inp: getNumericValue(audits, "interaction-to-next-paint"), + ttfb: getNumericValue(audits, "server-response-time"), + si: getNumericValue(audits, "speed-index"), + tbt: getNumericValue(audits, "total-blocking-time"), + }; + + // Split audits into opportunities (have savings) and diagnostics + const opportunities: PageSpeedAudit[] = []; + const diagnostics: PageSpeedAudit[] = []; + + for (const key of Object.keys(audits)) { + const audit = extractAudit(audits, key); + if (!audit) continue; + + const savings = audit.details?.overallSavingsMs ?? 0; + const bytesSavings = audit.details?.overallSavingsBytes ?? 0; + + if (savings > 0 || bytesSavings > 0) { + opportunities.push(audit); + } else if ( + audit.score !== null && + audit.score < 1 && + audit.details?.type === "table" + ) { + diagnostics.push(audit); + } + } + + // Sort opportunities by potential time savings descending + opportunities.sort( + (a, b) => + (b.details?.overallSavingsMs ?? 0) - (a.details?.overallSavingsMs ?? 0), + ); + + return { + performanceScore, + metrics, + opportunities, + diagnostics, + strategy, + fetchedAt: new Date().toISOString(), + }; +} diff --git a/packages/web-perf/server/lib/report.ts b/packages/web-perf/server/lib/report.ts new file mode 100644 index 0000000000..b5e97cb443 --- /dev/null +++ b/packages/web-perf/server/lib/report.ts @@ -0,0 +1,164 @@ +import type { + TrackedSite, + ReportData, + MetricRating, + Rating, + CrUXRecord, +} from "./types.ts"; +import { + CWV_THRESHOLDS, + rateMetric, + ratePerformanceScore, + formatMetricValue, + passesCWV, + type MetricName, +} from "./metrics.ts"; + +function priorityFromSavings( + savingsMs: number, +): "critical" | "high" | "medium" | "low" { + if (savingsMs >= 1000) return "critical"; + if (savingsMs >= 500) return "high"; + if (savingsMs >= 100) return "medium"; + return "low"; +} + +function worstRating(...ratings: Rating[]): Rating { + if (ratings.includes("poor")) return "poor"; + if (ratings.includes("needs-improvement")) return "needs-improvement"; + return "good"; +} + +function buildMetricRatings(crux: CrUXRecord): MetricRating[] { + const result: MetricRating[] = []; + for (const [key, info] of Object.entries(CWV_THRESHOLDS)) { + const metric = crux[key as MetricName]; + if (!metric) continue; + const value = metric.percentiles.p75; + result.push({ + name: key, + label: info.label, + value, + unit: info.unit, + rating: rateMetric(key as MetricName, value), + goodThreshold: info.good, + poorThreshold: info.poor, + }); + } + return result; +} + +function describeTrend(site: TrackedSite): string | undefined { + const history = site.cruxHistory; + if (!history || !history.record.lcp) return undefined; + + const lines: string[] = []; + for (const [key, info] of Object.entries(CWV_THRESHOLDS)) { + const metric = history.record[key as MetricName]; + if (!metric?.percentilesTimeseries?.p75s) continue; + const values = metric.percentilesTimeseries.p75s; + if (values.length < 4) continue; + + const recent = values.slice(-4); + const older = values.slice(-8, -4); + if (older.length === 0) continue; + + const recentAvg = recent.reduce((s, v) => s + v, 0) / recent.length; + const olderAvg = older.reduce((s, v) => s + v, 0) / older.length; + const change = ((recentAvg - olderAvg) / olderAvg) * 100; + + let direction: string; + if (Math.abs(change) < 3) direction = "stable"; + else if (change < 0) + direction = `improving (${Math.abs(change).toFixed(0)}% better)`; + else direction = `degrading (${change.toFixed(0)}% worse)`; + + lines.push(`${info.label}: ${direction}`); + } + + return lines.length > 0 ? lines.join(". ") : undefined; +} + +export function generateReport(site: TrackedSite): ReportData { + const latest = site.snapshots[0]; + if (!latest) { + return { + site, + overallRating: "poor", + cwvPass: false, + metrics: [], + opportunities: [], + recommendations: [ + "No snapshot data available. Run PERF_SNAPSHOT to collect performance data.", + ], + }; + } + + // Prefer phone CrUX data, fall back to all + const crux = latest.crux?.phone ?? latest.crux?.all; + const metrics = crux ? buildMetricRatings(crux) : []; + + const lcp = crux?.lcp?.percentiles.p75; + const inp = crux?.inp?.percentiles.p75; + const cls = crux?.cls?.percentiles.p75; + const cwvPass = passesCWV(lcp, inp, cls); + + const performanceScore = latest.pagespeed?.performanceScore; + const metricRatings = metrics.map((m) => m.rating); + const scoreRating = performanceScore + ? ratePerformanceScore(performanceScore) + : undefined; + const overallRating = worstRating( + ...metricRatings, + ...(scoreRating ? [scoreRating] : []), + ); + + const opportunities = (latest.pagespeed?.opportunities ?? []).map((opp) => { + const savingsMs = opp.details?.overallSavingsMs ?? 0; + const savingsBytes = opp.details?.overallSavingsBytes ?? 0; + const parts: string[] = []; + if (savingsMs > 0) parts.push(`${Math.round(savingsMs)}ms`); + if (savingsBytes > 0) parts.push(`${Math.round(savingsBytes / 1024)}KB`); + + return { + title: opp.title, + savings: parts.join(" / ") || "—", + savingsMs: savingsMs > 0 ? savingsMs : undefined, + savingsBytes: savingsBytes > 0 ? savingsBytes : undefined, + priority: priorityFromSavings(savingsMs), + description: opp.description.replace(/\[.*?\]\(.*?\)/g, "").trim(), + }; + }); + + const recommendations: string[] = []; + + // CWV-based recommendations + if (lcp && rateMetric("lcp", lcp) !== "good") { + recommendations.push( + `LCP is ${formatMetricValue("lcp", lcp)} (threshold: 2.5s). Optimize the largest content element: compress images, use next-gen formats (WebP/AVIF), preload critical resources, and reduce server response time.`, + ); + } + if (inp && rateMetric("inp", inp) !== "good") { + recommendations.push( + `INP is ${formatMetricValue("inp", inp)} (threshold: 200ms). Reduce JavaScript execution time: break up long tasks, defer non-critical scripts, optimize event handlers, and use web workers for heavy computation.`, + ); + } + if (cls && rateMetric("cls", cls) !== "good") { + recommendations.push( + `CLS is ${formatMetricValue("cls", cls)} (threshold: 0.1). Set explicit dimensions on images/videos, avoid inserting content above existing content, and use CSS containment for dynamic elements.`, + ); + } + + const trendSummary = describeTrend(site); + + return { + site, + overallRating, + performanceScore, + cwvPass, + metrics, + opportunities, + recommendations, + trendSummary, + }; +} diff --git a/packages/web-perf/server/lib/storage.ts b/packages/web-perf/server/lib/storage.ts new file mode 100644 index 0000000000..526740df64 --- /dev/null +++ b/packages/web-perf/server/lib/storage.ts @@ -0,0 +1,77 @@ +import { join } from "node:path"; +import { homedir } from "node:os"; +import type { TrackedSite, SiteSummary } from "./types.ts"; + +const SITES_DIR = join(homedir(), ".deco", "web-perf", "sites"); + +async function ensureDir(dir: string): Promise { + const { mkdir } = await import("node:fs/promises"); + await mkdir(dir, { recursive: true }); +} + +function sitePath(id: string): string { + return join(SITES_DIR, `${id}.json`); +} + +export async function saveSite(site: TrackedSite): Promise { + await ensureDir(SITES_DIR); + await Bun.write(sitePath(site.id), JSON.stringify(site, null, 2)); +} + +export async function loadSite(id: string): Promise { + const file = Bun.file(sitePath(id)); + if (!(await file.exists())) return null; + return file.json() as Promise; +} + +export async function listSites(): Promise { + await ensureDir(SITES_DIR); + const { readdir } = await import("node:fs/promises"); + const files = await readdir(SITES_DIR); + const sites: TrackedSite[] = []; + + for (const file of files) { + if (!file.endsWith(".json")) continue; + const site = (await Bun.file(join(SITES_DIR, file)).json()) as TrackedSite; + sites.push(site); + } + + return sites.sort( + (a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime(), + ); +} + +export async function listSiteSummaries(): Promise { + const sites = await listSites(); + return sites.map((site) => { + const latest = site.snapshots[0]; + const crux = latest?.crux?.phone ?? latest?.crux?.all; + return { + id: site.id, + name: site.name, + origin: site.origin, + snapshotCount: site.snapshots.length, + latestSnapshot: latest + ? { + timestamp: latest.timestamp, + performanceScore: latest.pagespeed?.performanceScore, + lcp: crux?.lcp?.percentiles.p75, + inp: crux?.inp?.percentiles.p75, + cls: crux?.cls?.percentiles.p75, + fcp: crux?.fcp?.percentiles.p75, + ttfb: crux?.ttfb?.percentiles.p75, + } + : undefined, + }; + }); +} + +export async function deleteSite(id: string): Promise { + const { unlink } = await import("node:fs/promises"); + try { + await unlink(sitePath(id)); + return true; + } catch { + return false; + } +} diff --git a/packages/web-perf/server/lib/types.ts b/packages/web-perf/server/lib/types.ts new file mode 100644 index 0000000000..3c395dff3c --- /dev/null +++ b/packages/web-perf/server/lib/types.ts @@ -0,0 +1,168 @@ +// ── Site & Config ── + +export interface SiteConfig { + id: string; + name: string; + origin: string; + apiKey?: string; + createdAt: string; + updatedAt: string; +} + +export interface TrackedSite extends SiteConfig { + snapshots: Snapshot[]; + cruxHistory?: CrUXHistoryData; +} + +export interface SiteSummary { + id: string; + name: string; + origin: string; + snapshotCount: number; + latestSnapshot?: { + timestamp: string; + performanceScore?: number; + lcp?: number; + inp?: number; + cls?: number; + fcp?: number; + ttfb?: number; + }; +} + +// ── Snapshot ── + +export interface Snapshot { + id: string; + timestamp: string; + crux?: CrUXData; + pagespeed?: PageSpeedData; +} + +// ── CrUX Types ── + +export interface CrUXHistogramEntry { + start: number; + end?: number; + density: number; +} + +export interface CrUXMetric { + histogram: CrUXHistogramEntry[]; + percentiles: { p75: number }; +} + +export interface CrUXRecord { + lcp?: CrUXMetric; + inp?: CrUXMetric; + cls?: CrUXMetric; + fcp?: CrUXMetric; + ttfb?: CrUXMetric; +} + +export interface CrUXData { + phone?: CrUXRecord; + desktop?: CrUXRecord; + all?: CrUXRecord; + collectionPeriod: { + firstDate: string; + lastDate: string; + }; +} + +// ── CrUX History ── + +export interface CrUXHistoryMetric { + histogramTimeseries: Array<{ + start: number; + end?: number; + densities: number[]; + }>; + percentilesTimeseries: { + p75s: number[]; + }; +} + +export interface CrUXHistoryRecord { + lcp?: CrUXHistoryMetric; + inp?: CrUXHistoryMetric; + cls?: CrUXHistoryMetric; + fcp?: CrUXHistoryMetric; + ttfb?: CrUXHistoryMetric; +} + +export interface CrUXHistoryData { + record: CrUXHistoryRecord; + collectionPeriods: Array<{ + firstDate: string; + lastDate: string; + }>; + fetchedAt: string; +} + +// ── PageSpeed Types ── + +export interface PageSpeedAudit { + id: string; + title: string; + description: string; + score: number | null; + displayValue?: string; + numericValue?: number; + numericUnit?: string; + details?: { + type: string; + overallSavingsMs?: number; + overallSavingsBytes?: number; + items?: Array>; + }; +} + +export interface PageSpeedData { + performanceScore: number; + metrics: { + fcp: number; + lcp: number; + cls: number; + inp: number; + ttfb: number; + si: number; + tbt: number; + }; + opportunities: PageSpeedAudit[]; + diagnostics: PageSpeedAudit[]; + strategy: "mobile" | "desktop"; + fetchedAt: string; +} + +// ── Report Types ── + +export type Rating = "good" | "needs-improvement" | "poor"; + +export interface MetricRating { + name: string; + label: string; + value: number; + unit: string; + rating: Rating; + goodThreshold: number; + poorThreshold: number; +} + +export interface ReportData { + site: SiteConfig; + overallRating: Rating; + performanceScore?: number; + cwvPass: boolean; + metrics: MetricRating[]; + opportunities: Array<{ + title: string; + savings: string; + savingsMs?: number; + savingsBytes?: number; + priority: "critical" | "high" | "medium" | "low"; + description: string; + }>; + recommendations: string[]; + trendSummary?: string; +} diff --git a/packages/web-perf/server/tools/crux-history.ts b/packages/web-perf/server/tools/crux-history.ts new file mode 100644 index 0000000000..8058faa9ba --- /dev/null +++ b/packages/web-perf/server/tools/crux-history.ts @@ -0,0 +1,57 @@ +import { createTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import { loadSite, saveSite } from "../lib/storage.ts"; +import { fetchCrUXHistory } from "../lib/crux.ts"; + +export const CRUX_HISTORY = createTool({ + id: "CRUX_HISTORY", + description: + "Fetch Chrome UX Report historical data for a tracked site (25 weekly data points). Used for trend analysis and sparkline charts. Requires a Google API key.", + annotations: { + title: "CrUX History", + openWorldHint: true, + }, + _meta: { + ui: { resourceUri: "ui://web-perf/site-detail" }, + }, + inputSchema: z.object({ + siteId: z.string().describe("The site ID"), + apiKey: z + .string() + .optional() + .describe("Google API key (overrides site-level key)"), + formFactor: z + .enum(["PHONE", "DESKTOP", "ALL_FORM_FACTORS"]) + .optional() + .default("PHONE") + .describe("Device type for CrUX data"), + }), + execute: async ({ context }) => { + const site = await loadSite(context.siteId); + if (!site) throw new Error(`Site not found: ${context.siteId}`); + + const apiKey = context.apiKey ?? site.apiKey ?? process.env.GOOGLE_API_KEY; + if (!apiKey) { + throw new Error( + "No API key provided. Pass apiKey as a parameter, configure it on the site, or set GOOGLE_API_KEY env var.", + ); + } + + const history = await fetchCrUXHistory( + site.origin, + apiKey, + context.formFactor, + ); + + site.cruxHistory = history; + site.updatedAt = new Date().toISOString(); + await saveSite(site); + + const dataPoints = history.collectionPeriods.length; + return { + history, + site: { id: site.id, name: site.name, origin: site.origin }, + message: `Fetched ${dataPoints} weeks of CrUX history data for ${site.origin}.`, + }; + }, +}); diff --git a/packages/web-perf/server/tools/perf-report.ts b/packages/web-perf/server/tools/perf-report.ts new file mode 100644 index 0000000000..751ce40bbe --- /dev/null +++ b/packages/web-perf/server/tools/perf-report.ts @@ -0,0 +1,27 @@ +import { createTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import { loadSite } from "../lib/storage.ts"; +import { generateReport } from "../lib/report.ts"; + +export const PERF_REPORT = createTool({ + id: "PERF_REPORT", + description: + "Generate a structured performance report for a tracked site with Core Web Vitals ratings, PageSpeed opportunities, and actionable recommendations. Uses the latest snapshot data.", + annotations: { + title: "Performance Report", + readOnlyHint: true, + }, + _meta: { + ui: { resourceUri: "ui://web-perf/site-detail" }, + }, + inputSchema: z.object({ + siteId: z.string().describe("The site ID to generate a report for"), + }), + execute: async ({ context }) => { + const site = await loadSite(context.siteId); + if (!site) throw new Error(`Site not found: ${context.siteId}`); + + const report = generateReport(site); + return { report }; + }, +}); diff --git a/packages/web-perf/server/tools/perf-snapshot.ts b/packages/web-perf/server/tools/perf-snapshot.ts new file mode 100644 index 0000000000..4f98efcebf --- /dev/null +++ b/packages/web-perf/server/tools/perf-snapshot.ts @@ -0,0 +1,116 @@ +import { createTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import { loadSite, saveSite } from "../lib/storage.ts"; +import { fetchCrUXData } from "../lib/crux.ts"; +import { fetchPageSpeed } from "../lib/pagespeed.ts"; +import { rateMetric, formatMetricValue, passesCWV } from "../lib/metrics.ts"; +import type { Snapshot } from "../lib/types.ts"; + +const MAX_SNAPSHOTS = 50; + +export const PERF_SNAPSHOT = createTool({ + id: "PERF_SNAPSHOT", + description: + "Collect a performance snapshot for a tracked site. Fetches real-user data from Chrome UX Report (CrUX) and runs a Lighthouse lab test via PageSpeed Insights API. Requires a Google API key.", + annotations: { + title: "Take Snapshot", + openWorldHint: true, + }, + _meta: { + ui: { resourceUri: "ui://web-perf/site-detail" }, + }, + inputSchema: z.object({ + siteId: z.string().describe("The site ID to snapshot"), + apiKey: z + .string() + .optional() + .describe("Google API key (overrides site-level key)"), + strategy: z + .enum(["mobile", "desktop"]) + .optional() + .default("mobile") + .describe("PageSpeed test strategy"), + }), + execute: async ({ context }) => { + const site = await loadSite(context.siteId); + if (!site) throw new Error(`Site not found: ${context.siteId}`); + + const apiKey = context.apiKey ?? site.apiKey ?? process.env.GOOGLE_API_KEY; + if (!apiKey) { + throw new Error( + "No API key provided. Pass apiKey as a parameter, configure it on the site, or set GOOGLE_API_KEY env var.", + ); + } + + // Fetch CrUX and PageSpeed in parallel + const [crux, pagespeed] = await Promise.all([ + fetchCrUXData(site.origin, apiKey).catch((e) => { + console.error("CrUX fetch failed:", e); + return undefined; + }), + fetchPageSpeed(site.origin, apiKey, context.strategy).catch((e) => { + console.error("PageSpeed fetch failed:", e); + return undefined; + }), + ]); + + const snapshot: Snapshot = { + id: crypto.randomUUID().slice(0, 8), + timestamp: new Date().toISOString(), + crux, + pagespeed, + }; + + // Prepend snapshot, cap at MAX_SNAPSHOTS + site.snapshots.unshift(snapshot); + if (site.snapshots.length > MAX_SNAPSHOTS) { + site.snapshots = site.snapshots.slice(0, MAX_SNAPSHOTS); + } + site.updatedAt = new Date().toISOString(); + await saveSite(site); + + // Build summary + const cruxRecord = crux?.phone ?? crux?.all; + const lcp = cruxRecord?.lcp?.percentiles.p75; + const inp = cruxRecord?.inp?.percentiles.p75; + const cls = cruxRecord?.cls?.percentiles.p75; + + const summaryParts: string[] = []; + if (pagespeed) { + summaryParts.push(`Performance score: ${pagespeed.performanceScore}/100`); + } + if (lcp !== undefined) + summaryParts.push( + `LCP: ${formatMetricValue("lcp", lcp)} (${rateMetric("lcp", lcp)})`, + ); + if (inp !== undefined) + summaryParts.push( + `INP: ${formatMetricValue("inp", inp)} (${rateMetric("inp", inp)})`, + ); + if (cls !== undefined) + summaryParts.push( + `CLS: ${formatMetricValue("cls", cls)} (${rateMetric("cls", cls)})`, + ); + if (lcp !== undefined && inp !== undefined && cls !== undefined) { + summaryParts.push( + `Core Web Vitals: ${passesCWV(lcp, inp, cls) ? "PASSED" : "FAILED"}`, + ); + } + + if (!crux && !pagespeed) { + summaryParts.push( + "Warning: Both CrUX and PageSpeed requests failed. Check your API key and that the site has sufficient traffic for CrUX data.", + ); + } else if (!crux) { + summaryParts.push( + "Note: CrUX data unavailable (site may not have enough traffic for field data). Lab data only.", + ); + } + + return { + snapshot, + site: { id: site.id, name: site.name, origin: site.origin }, + summary: summaryParts.join(". "), + }; + }, +}); diff --git a/packages/web-perf/server/tools/site-add.ts b/packages/web-perf/server/tools/site-add.ts new file mode 100644 index 0000000000..99cdb17110 --- /dev/null +++ b/packages/web-perf/server/tools/site-add.ts @@ -0,0 +1,59 @@ +import { createTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import { saveSite } from "../lib/storage.ts"; +import type { TrackedSite } from "../lib/types.ts"; + +export const SITE_ADD = createTool({ + id: "SITE_ADD", + description: + "Add a website to track for performance monitoring. Specify the origin URL (e.g., https://example.com) and an optional Google API key.", + annotations: { + title: "Add Site", + }, + _meta: { + ui: { resourceUri: "ui://web-perf/dashboard" }, + }, + inputSchema: z.object({ + name: z.string().describe("Friendly name for the site"), + origin: z + .string() + .describe("Origin URL to track (e.g., https://example.com)"), + apiKey: z + .string() + .optional() + .describe("Google API key for CrUX and PageSpeed APIs"), + }), + execute: async ({ context }) => { + // Normalize origin: remove trailing slash + const origin = context.origin.replace(/\/+$/, ""); + + try { + new URL(origin); + } catch { + throw new Error(`Invalid URL: ${origin}`); + } + + const id = crypto.randomUUID().slice(0, 8); + const now = new Date().toISOString(); + + const site: TrackedSite = { + id, + name: context.name, + origin, + apiKey: context.apiKey, + snapshots: [], + createdAt: now, + updatedAt: now, + }; + + await saveSite(site); + + return { + id: site.id, + name: site.name, + origin: site.origin, + createdAt: site.createdAt, + message: `Site "${site.name}" (${site.origin}) added. Use PERF_SNAPSHOT to collect performance data.`, + }; + }, +}); diff --git a/packages/web-perf/server/tools/site-delete.ts b/packages/web-perf/server/tools/site-delete.ts new file mode 100644 index 0000000000..ee1505709f --- /dev/null +++ b/packages/web-perf/server/tools/site-delete.ts @@ -0,0 +1,25 @@ +import { createTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import { deleteSite } from "../lib/storage.ts"; + +export const SITE_DELETE = createTool({ + id: "SITE_DELETE", + description: "Remove a tracked website and all its stored performance data.", + annotations: { + title: "Delete Site", + destructiveHint: true, + }, + inputSchema: z.object({ + siteId: z.string().describe("The site ID to delete"), + }), + execute: async ({ context }) => { + const deleted = await deleteSite(context.siteId); + return { + deleted, + siteId: context.siteId, + message: deleted + ? `Site ${context.siteId} deleted.` + : `Site ${context.siteId} not found.`, + }; + }, +}); diff --git a/packages/web-perf/server/tools/site-get.ts b/packages/web-perf/server/tools/site-get.ts new file mode 100644 index 0000000000..bcf2c98b2f --- /dev/null +++ b/packages/web-perf/server/tools/site-get.ts @@ -0,0 +1,26 @@ +import { createTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import { loadSite } from "../lib/storage.ts"; + +export const SITE_GET = createTool({ + id: "SITE_GET", + description: + "Get full details for a tracked site including all snapshots and CrUX history data.", + annotations: { + title: "Get Site", + readOnlyHint: true, + }, + _meta: { + ui: { resourceUri: "ui://web-perf/site-detail" }, + }, + inputSchema: z.object({ + siteId: z.string().describe("The site ID to retrieve"), + }), + execute: async ({ context }) => { + const site = await loadSite(context.siteId); + if (!site) { + throw new Error(`Site not found: ${context.siteId}`); + } + return { site }; + }, +}); diff --git a/packages/web-perf/server/tools/site-list.ts b/packages/web-perf/server/tools/site-list.ts new file mode 100644 index 0000000000..fbc9e5d6d7 --- /dev/null +++ b/packages/web-perf/server/tools/site-list.ts @@ -0,0 +1,28 @@ +import { createTool } from "@decocms/runtime/tools"; +import { z } from "zod"; +import { listSiteSummaries } from "../lib/storage.ts"; + +export const SITE_LIST = createTool({ + id: "SITE_LIST", + description: + "List all tracked websites with their latest performance scores and Core Web Vitals.", + annotations: { + title: "List Sites", + readOnlyHint: true, + }, + _meta: { + ui: { resourceUri: "ui://web-perf/dashboard" }, + }, + inputSchema: z.object({}), + execute: async () => { + const sites = await listSiteSummaries(); + return { + sites, + count: sites.length, + message: + sites.length === 0 + ? "No sites tracked yet. Use SITE_ADD to start monitoring a website." + : `${sites.length} site${sites.length > 1 ? "s" : ""} tracked.`, + }; + }, +}); diff --git a/packages/web-perf/server/ui/dashboard.ts b/packages/web-perf/server/ui/dashboard.ts new file mode 100644 index 0000000000..9b445e7a3d --- /dev/null +++ b/packages/web-perf/server/ui/dashboard.ts @@ -0,0 +1,156 @@ +import { SHARED_STYLES, APPBRIDGE_SCRIPT } from "./shared-styles.ts"; + +export function renderDashboard(): string { + return ` + +
+
+ + + +

Loading performance data...

+
+
+${APPBRIDGE_SCRIPT} +`; +} diff --git a/packages/web-perf/server/ui/shared-styles.ts b/packages/web-perf/server/ui/shared-styles.ts new file mode 100644 index 0000000000..0168f9bfff --- /dev/null +++ b/packages/web-perf/server/ui/shared-styles.ts @@ -0,0 +1,179 @@ +export const SHARED_STYLES = ` + * { margin: 0; padding: 0; box-sizing: border-box; } + body { + font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif); + background: var(--color-background-primary, #ffffff); + color: var(--color-text-primary, #0f172a); + line-height: 1.5; + padding: 16px; + } + .muted { color: var(--color-text-secondary, #64748b); } + .small { font-size: 12px; } + .badge { + display: inline-block; + padding: 2px 8px; + border-radius: 9999px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + } + .badge-good { background: #dcfce7; color: #15803d; } + .badge-needs-improvement { background: #fef3c7; color: #a16207; } + .badge-poor { background: #fee2e2; color: #dc2626; } + .card { + border: 1px solid var(--color-border-primary, #e2e8f0); + border-radius: var(--border-radius-md, 8px); + padding: 12px 16px; + background: var(--color-background-primary, #ffffff); + } + .card:hover { background: var(--color-background-secondary, #f8fafc); } + .grid { display: grid; gap: 12px; } + .grid-2 { grid-template-columns: repeat(2, 1fr); } + .grid-3 { grid-template-columns: repeat(3, 1fr); } + .grid-5 { grid-template-columns: repeat(5, 1fr); } + .flex { display: flex; align-items: center; } + .flex-col { display: flex; flex-direction: column; } + .gap-1 { gap: 4px; } + .gap-2 { gap: 8px; } + .gap-3 { gap: 12px; } + .gap-4 { gap: 16px; } + .justify-between { justify-content: space-between; } + .font-medium { font-weight: 500; } + .font-semibold { font-weight: 600; } + .font-bold { font-weight: 700; } + .text-lg { font-size: 18px; } + .text-sm { font-size: 14px; } + .text-xs { font-size: 12px; } + .text-2xl { font-size: 24px; } + .text-center { text-align: center; } + .truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .w-full { width: 100%; } + .mt-1 { margin-top: 4px; } + .mt-2 { margin-top: 8px; } + .mt-3 { margin-top: 12px; } + .mt-4 { margin-top: 16px; } + .mb-2 { margin-bottom: 8px; } + .mb-3 { margin-bottom: 12px; } + .mb-4 { margin-bottom: 16px; } + .p-3 { padding: 12px; } + .p-4 { padding: 16px; } + .rounded { border-radius: var(--border-radius-md, 8px); } + .border { border: 1px solid var(--color-border-primary, #e2e8f0); } + .bg-muted { background: var(--color-background-secondary, #f8fafc); } + table { width: 100%; border-collapse: collapse; font-size: 13px; } + th { + text-align: left; padding: 8px 12px; font-weight: 600; + font-size: 11px; text-transform: uppercase; letter-spacing: 0.5px; + color: var(--color-text-secondary, #64748b); + border-bottom: 2px solid var(--color-border-primary, #e2e8f0); + } + td { + padding: 10px 12px; + border-bottom: 1px solid var(--color-border-primary, #e2e8f0); + vertical-align: middle; + } + tr:hover td { background: var(--color-background-secondary, #f8fafc); } + .empty-state { + display: flex; flex-direction: column; align-items: center; + justify-content: center; padding: 48px 24px; text-align: center; + } + .empty-state svg { margin-bottom: 16px; opacity: 0.4; } + .section-title { + font-size: 14px; font-weight: 600; text-transform: uppercase; + letter-spacing: 0.5px; color: var(--color-text-secondary, #64748b); + margin-bottom: 12px; + } + + /* CWV-specific colors */ + .color-good { color: #0cce6b; } + .color-needs-improvement { color: #ffa400; } + .color-poor { color: #ff4e42; } + .bg-good { background: #0cce6b; } + .bg-needs-improvement { background: #ffa400; } + .bg-poor { background: #ff4e42; } + + @media (max-width: 640px) { + .grid-2, .grid-3, .grid-5 { grid-template-columns: 1fr; } + } +`; + +export const APPBRIDGE_SCRIPT = ` + +`; diff --git a/packages/web-perf/server/ui/site-detail.ts b/packages/web-perf/server/ui/site-detail.ts new file mode 100644 index 0000000000..473cbc03d4 --- /dev/null +++ b/packages/web-perf/server/ui/site-detail.ts @@ -0,0 +1,346 @@ +import { SHARED_STYLES, APPBRIDGE_SCRIPT } from "./shared-styles.ts"; + +export function renderSiteDetail(apiOrigin: string): string { + return ` + +
+
+ + + +

Loading site details...

+
+
+${APPBRIDGE_SCRIPT} +`; +} diff --git a/packages/web-perf/tsconfig.json b/packages/web-perf/tsconfig.json new file mode 100644 index 0000000000..1df7a2fa35 --- /dev/null +++ b/packages/web-perf/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": ".", + "declaration": true, + "declarationMap": true + }, + "include": ["**/*.ts"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +}