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
);
})}
}
{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 => (
);
};
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.
);
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 && (
)}
);
};
/** 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.
Largest tablesRowsSize
{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.
);
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 => (
))}
);
};
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.