import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ReactNode } from 'react'; import type { ChartData, ChartOptions, TooltipItem } from 'chart.js'; import { CircleCheckIcon, ExternalLinkIcon, GaugeIcon, LoaderCircleIcon, TrendingDownIcon, TrendingUpIcon, WrenchIcon, } from 'lucide-react'; import { useNavigate } from 'react-router-dom'; import { toast } from 'sonner'; import ChartCanvas from '../../components/agent-analytics/ChartCanvas'; import { OptionTip } from '../../components/seo/option-row'; import { SeoPage, SeoPageError, SeoPageHeader, SeoPageLoading, } from '../../components/seo/seo-page'; import { Badge } from '../../components/ui/badge'; import { Button } from '../../components/ui/button'; import { Callout } from '../../components/ui/callout'; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from '../../components/ui/card'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, } from '../../components/ui/table'; import { Tabs, TabsList, TabsTrigger } from '../../components/ui/tabs'; import { useSeoBadges } from '../../context/seo-badges.context'; import { dayOf, eventLabel, eventsByDay, formatBytes, formatMs, trendOf, trendTone, waitForMeasurement, type SeoPerfMetric, } from '../../lib/seo/performance'; import { timeAgo } from '../../lib/seo/time-ago'; import { classNames } from '../../lib/utils'; import type { ISeoPerfAction, ISeoPerfPage, ISeoPerformance, ISeoServerFacts, } from '../../service/seo/seo.interface'; import { fetchSeoPerformance, fetchSeoPerformanceJob, fixSeoCheck, startSeoPerformance, } from '../../service/seo/seo.service'; import { PAGES } from './settings.copy'; const LINE_COLOR = '#C8175D'; const MARKER_COLOR = '#d97706'; const GRID_COLOR = '#e5e7eb'; const TICK_COLOR = '#6b7280'; /** What the history chart can follow, and how its values read. */ const METRICS: { key: SeoPerfMetric; label: string; tip: string; format: (value: number | null) => string; }[] = [ { key: 'server_ms', label: 'Response time', tip: 'How long the front page took to come back from the server to itself, past the page cache: the time WordPress, the theme and the plugins need to build it. The median of three loads.', format: formatMs, }, { key: 'php_ms', label: 'PHP time', tip: 'What the front page reported itself: the time from the request reaching PHP to the last line of HTML.', format: formatMs, }, { key: 'html_bytes', label: 'HTML size', tip: 'The front page’s HTML alone, before images, scripts and styles, uncompressed.', format: formatBytes, }, { key: 'db_bytes', label: 'Database', tip: 'The size of every table of this site in the database, data and indexes.', format: formatBytes, }, { key: 'autoload_bytes', label: 'Autoload', tip: 'The options WordPress loads on every request before anything else.', format: formatBytes, }, ]; const PERIODS = [30, 90]; /** A figure's label with the tip that says what it measures. */ const Label = ({ children, tip, }: { children: ReactNode; tip: ReactNode; }): JSX.Element => ( {children} {tip} ); /** How a figure moved over a week and a month, coloured when it matters. */ const TrendLine = ({ performance, metric, }: { performance: ISeoPerformance; metric: SeoPerfMetric; }): JSX.Element | null => { const parts = [7, 30] .map(days => ({ days, trend: trendOf(performance.history, metric, days) })) .filter(({ trend }) => trend !== null); if (parts.length === 0) { return null; } return (
{parts.map(({ days, trend }) => { if (!trend) return null; const tone = trendTone(trend.change); const Icon = trend.change < 0 ? TrendingDownIcon : TrendingUpIcon; return ( {trend.change > 0 ? '+' : ''} {trend.change}% in {days} days ); })}
); }; const Figure = ({ label, tip, value, detail, children, }: { label: string; tip: ReactNode; value: ReactNode; detail?: ReactNode; children?: ReactNode; }): JSX.Element => (
{value}
{detail &&

{detail}

} {children}
); /** A row of the caches figure: what runs, and whether it does. */ const CacheRow = ({ name, on, detail, }: { name: string; on: boolean; detail: string; }): JSX.Element => (
{name} {detail}
); const HeaderFigures = ({ performance, }: { performance: ISeoPerformance; }): JSX.Element | null => { const latest = performance.latest; const home = latest?.pages.find(page => page.key === 'home'); const server = latest?.server ?? performance.server; const pageCache = latest?.page_cache || home?.cache || ''; return (
); }; const COLUMNS: { label: string; tip: string }[] = [ { label: 'Server', tip: 'The median of three loads past the page cache, from the server to itself.', }, { label: 'Visitor', tip: 'A load as a visitor asks for it, through any page cache: what most people get.', }, { label: 'PHP', tip: 'The time PHP took to build the page, as the page reported it.', }, { label: 'Queries', tip: 'Database queries the page ran. A few dozen is usual; hundreds point at a plugin.', }, { label: 'Memory', tip: 'The most memory PHP held while building the page.', }, { label: 'HTML', tip: 'The page’s HTML, uncompressed, before images, scripts and styles.', }, { label: 'Sent as', tip: 'Whether the HTML left the server compressed (gzip, br) or not.', }, ]; const PagesCard = ({ pages }: { pages: ISeoPerfPage[] }): JSX.Element => ( Pages measured One page of each kind the site has, loaded from the server itself: three times past the page cache, then as a visitor would. Page {COLUMNS.map(column => ( {column.label} {column.tip} ))} {pages.map(page => ( {page.label} {page.title && (
{page.title}
)} {page.error && (
{page.error}
)}
{formatMs(page.server_ms)} {formatMs(page.cached_ms)} {page.cache && (
cached
)}
{formatMs(page.php_ms)} {page.queries ?? '—'} {formatBytes(page.memory)} {formatBytes(page.html_bytes)} {page.encoding ? ( {page.encoding} ) : page.server_ms != null ? ( uncompressed ) : ( '—' )}
))}
); const HistoryCard = ({ performance, }: { performance: ISeoPerformance; }): JSX.Element => { const [metric, setMetric] = useState('server_ms'); const [days, setDays] = useState(30); const chosen = METRICS.find(item => item.key === metric) ?? METRICS[0]; const since = Date.now() - days * 24 * 60 * 60 * 1000; const points = performance.history.filter( point => new Date(point.at).getTime() >= since ); const labels = points.map(point => dayOf(point.at)); const events = performance.events.filter( event => new Date(event.at).getTime() >= since ); const placed = eventsByDay(events, labels); const values = points.map(point => point[metric]); const top = Math.max(0, ...values.map(value => value ?? 0)); const data = useMemo( () => ({ labels: labels.map(day => new Date(`${day}T12:00:00Z`).toLocaleDateString(undefined, { day: 'numeric', month: 'short', }) ), datasets: [ { type: 'line' as const, label: chosen.label, data: values, borderColor: LINE_COLOR, backgroundColor: 'rgba(200, 23, 93, 0.12)', fill: true, tension: 0.3, spanGaps: true, pointRadius: 2, }, { type: 'line' as const, label: 'Changes on the site', data: labels.map(day => (placed[day] ? top * 1.08 || 1 : null)), borderColor: MARKER_COLOR, backgroundColor: MARKER_COLOR, showLine: false, pointStyle: 'triangle', pointRadius: 6, pointHoverRadius: 8, }, ], }), [JSON.stringify(labels), JSON.stringify(values), metric, top] ); const options = useMemo( () => ({ plugins: { legend: { display: false }, tooltip: { callbacks: { label: (item: TooltipItem<'line'>) => item.datasetIndex === 1 ? (placed[labels[item.dataIndex]] ?? []).map(eventLabel) : `${chosen.label}: ${chosen.format(item.parsed.y)}`, }, }, }, scales: { x: { grid: { display: false }, ticks: { color: TICK_COLOR, maxRotation: 0, autoSkip: true }, }, y: { beginAtZero: true, grid: { color: GRID_COLOR }, ticks: { color: TICK_COLOR, callback: value => chosen.format(Number(value)), }, }, }, }), [metric, JSON.stringify(placed), JSON.stringify(labels)] ); return ( History The front page and the server, one measurement a day. A triangle marks a day after a plugin, a theme or WordPress changed: hover it to see what.
setMetric(next as SeoPerfMetric)} > {METRICS.map(item => ( {item.label} ))} setDays(Number(next))} > {PERIODS.map(period => ( {period} days ))}

{chosen.tip}

{points.length < 2 ? ( The chart fills in day by day: the dashboard measures once a day, with the checkup. {points.length === 1 ? 'One day so far.' : ''} ) : ( )} {events.length > 0 && (

Changes on the site

    {[...events].reverse().map(event => (
  • {eventLabel(event)}{' '} · {new Date(event.at).toLocaleString()}
  • ))}
)}
); }; /** One number of the server, with what it means. */ const Fact = ({ label, tip, value, warn = false, }: { label: string; tip: string; value: ReactNode; warn?: boolean; }): JSX.Element => (
{value}
); const CLUTTER: Record = { expired_transients: { label: 'Expired transients', tip: 'Cache rows in the options table past their time. WordPress sweeps them daily through WP-Cron; thousands mean the sweep is not running.', }, revisions: { label: 'Revisions', tip: 'Older versions of posts and pages. Useful for going back; WP_POST_REVISIONS in wp-config.php caps how many each post keeps.', }, auto_drafts: { label: 'Auto-drafts', tip: 'Drafts the editor makes when a new post is opened and never filled.', }, trash: { label: 'Posts in the trash', tip: 'WordPress empties the trash after 30 days.', }, spam: { label: 'Spam comments', tip: 'Comments marked as spam, kept until deleted.', }, trashed_comments: { label: 'Comments in the trash', tip: 'Comments moved to the trash, emptied after 30 days.', }, orphan_postmeta: { label: 'Meta of deleted posts', tip: 'Rows of post meta whose post no longer exists: left by plugins that delete posts without their meta.', }, orphan_commentmeta: { label: 'Meta of deleted comments', tip: 'Rows of comment meta whose comment no longer exists.', }, }; const ServerCard = ({ server }: { server: ISeoServerFacts }): JSX.Element => ( The server Read when this page opened: what PHP, the caches and the database have now.

PHP

0 ? formatBytes(server.php.memory_limit) : 'no limit' } /> 0 ? `${server.php.max_execution_time} s` : 'unlimited' } /> 0} /> {server.action_scheduler && ( 0} /> )} {server.disk.free != null && server.disk.total != null && ( )}

Database

Largest tables Rows Size {server.database.tables.map(table => ( {table.name} {table.rows.toLocaleString()} {formatBytes(table.size)} ))}

Autoloaded options Options WordPress loads on every request before anything else. Over {formatBytes(server.autoload.limit)}, every page starts slower. The owner is guessed from the name; one that matches no plugin was often left by a plugin removed long ago. Recomaze never changes them: delete the plugin’s data from its own settings, or ask whoever runs the site.

server.autoload.limit} /> Option Probably from Size {server.autoload.top.map(option => ( {option.name} {option.owner || 'unknown, maybe a removed plugin'} {formatBytes(option.size)} ))}

What has piled up

{Object.entries(server.clutter).map(([key, count]) => ( ))}
); const ActionsCard = ({ actions, busy, onFix, }: { actions: ISeoPerfAction[]; busy: string | null; onFix: (action: ISeoPerfAction) => void; }): JSX.Element | null => { const navigate = useNavigate(); if (actions.length === 0) { return null; } const open = actions.filter(action => !action.passed); return ( What would make it faster From the last checkup run. A button here fixes it on the site and keeps a way back; the rest is a plugin to add or a word with the host. {open.length === 0 && ( Nothing to fix: compression, the caches and the database pass. )} {open.map(action => (
{action.title} {action.description}
{action.fix?.type === 'local' && ( )} {action.fix?.type === 'link' && action.fix.to && ( )} {action.fix?.type === 'external' && action.fix.to && ( )}
))} {actions .filter(action => action.passed) .map(action => (
{action.title}
))}
); }; const ErrorsCard = ({ errors, }: { errors: ISeoPerformance['errors']; }): JSX.Element | null => errors.length === 0 ? null : ( Recent PHP errors Fatal errors Recomaze caught on this site: the ones in its own code, and any in the day after another SEO plugin was switched off. Each one went to Recomaze support too.
    {errors.map(error => (
  • {error.message} {new Date(error.time * 1000).toLocaleString()} {error.file ? ` · ${error.file}:${error.line}` : ''}
  • ))}
); /** * SEO → Performance: how fast the site answers and what the server under * it holds, measured from the server itself every day, with the history, * the changes that explain it and the fixes a click away. */ const SeoPerformance = (): JSX.Element => { const [performance, setPerformance] = useState(null); const [error, setError] = useState(null); const [measuring, setMeasuring] = useState(false); const [fixing, setFixing] = useState(null); const badges = useSeoBadges(); const here = useRef(true); useEffect( () => () => { here.current = false; }, [] ); const load = useCallback(async (fresh = false): Promise => { try { setPerformance(await fetchSeoPerformance(fresh)); setError(null); } catch (reason) { setError((reason as Error).message); } }, []); useEffect(() => { void load(); }, [load]); const measure = async (): Promise => { setMeasuring(true); try { const { job } = await startSeoPerformance(); const answer = await waitForMeasurement(job, { ask: fetchSeoPerformanceJob, cancelled: () => !here.current, }); setPerformance(answer); toast.success( `Measured ${answer.latest?.pages.length ?? 0} pages of the site.` ); } catch (reason) { if ((reason as { code?: string }).code !== 'cancelled') { toast.error((reason as Error).message); } } finally { if (here.current) setMeasuring(false); } }; const fix = async (action: ISeoPerfAction): Promise => { setFixing(action.id); try { const result = await fixSeoCheck(action.id); (result.fixed ? toast.success : toast.info)(result.message); badges.refresh(); await load(true); } catch (reason) { toast.error((reason as Error).message); } finally { setFixing(null); } }; if (error && !performance) { return ; } if (!performance) { return ; } return ( Measured {timeAgo(performance.latest.measured_at)} ) : undefined } actions={ } /> {error && {error}} {!performance.latest && ( Nothing measured yet. The dashboard measures once a day with the checkup; Measure now loads the pages right away. )} {performance.latest && } void fix(action)} /> ); }; export default SeoPerformance;