feat: improve SEO feature

This commit is contained in:
2026-08-31 23:12:38 +07:00
parent 025a1d66f7
commit c525e8e3a2
28 changed files with 715 additions and 29 deletions
+62
View File
@@ -0,0 +1,62 @@
import { useEffect } from 'react';
type SeoOptions = {
title: string;
description?: string;
noindex?: boolean;
canonicalPath?: string;
};
const managedAttribute = 'data-upwatch-seo';
const defaultDescription = "Fast, dependable uptime monitoring from Cloudflare's edge.";
function upsertMeta(selector: string, attributes: Record<string, string>, content: string) {
let element = document.head.querySelector<HTMLMetaElement>(selector);
if (!element) {
element = document.createElement('meta');
for (const [name, value] of Object.entries(attributes)) element.setAttribute(name, value);
document.head.append(element);
}
element.setAttribute('content', content);
element.setAttribute(managedAttribute, 'true');
}
function removeManaged(selector: string) {
document.head.querySelector(`${selector}[${managedAttribute}="true"]`)?.remove();
}
function removeNoindex() {
const robots = document.head.querySelector<HTMLMetaElement>('meta[name="robots"]');
if (robots?.content.includes('noindex')) robots.remove();
}
function absoluteUrl(pathname: string) {
const configuredBase = import.meta.env.VITE_PUBLIC_BASE_URL?.replace(/\/$/, '');
return new URL(pathname, configuredBase || window.location.origin).toString();
}
export function useSeo({ title, description, noindex = false, canonicalPath }: SeoOptions) {
useEffect(() => {
const resolvedDescription = description ?? defaultDescription;
document.title = title;
upsertMeta('meta[property="og:title"]', { property: 'og:title' }, title);
upsertMeta('meta[name="twitter:title"]', { name: 'twitter:title' }, title);
upsertMeta('meta[name="description"]', { name: 'description' }, resolvedDescription);
upsertMeta('meta[property="og:description"]', { property: 'og:description' }, resolvedDescription);
upsertMeta('meta[name="twitter:description"]', { name: 'twitter:description' }, resolvedDescription);
if (noindex) upsertMeta('meta[name="robots"]', { name: 'robots' }, 'noindex,nofollow');
else removeNoindex();
if (canonicalPath) {
let canonical = document.head.querySelector<HTMLLinkElement>('link[rel="canonical"]');
if (!canonical) {
canonical = document.createElement('link');
canonical.rel = 'canonical';
document.head.append(canonical);
}
canonical.href = absoluteUrl(canonicalPath);
canonical.setAttribute(managedAttribute, 'true');
} else removeManaged('link[rel="canonical"]');
}, [canonicalPath, description, noindex, title]);
}
+2
View File
@@ -6,10 +6,12 @@ import { DashboardOverview } from '../components/dashboard/DashboardOverview';
import { IncidentsPanel } from '../components/dashboard/IncidentsPanel';
import { MonitorFormDialog } from '../components/dashboard/MonitorFormDialog';
import { MonitorListPanel } from '../components/dashboard/MonitorListPanel';
import { useSeo } from '../lib/seo';
export function DashboardPage() {
const [editing, setEditing] = useState<Monitor | null>(null);
const [formOpen, setFormOpen] = useState(false);
useSeo({ title: 'Dashboard — upwatch', noindex: true });
function openCreateForm() {
setEditing(null);
+16
View File
@@ -3,12 +3,28 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { IncidentTimeline } from '../components/IncidentTimeline';
import { navigate } from '../lib/router';
import { useSeo } from '../lib/seo';
import { usePublicIncidentQuery } from '../queries/status';
const dateTime = new Intl.DateTimeFormat(undefined, { dateStyle: 'long', timeStyle: 'short' });
export function IncidentDetailPage({ id }: { id: number }) {
const query = usePublicIncidentQuery(id);
const incident = query.data?.incident;
const latestUpdate = incident?.updates?.at(-1)?.body;
const fallbackDescription = incident
? `${incident.services.map((service) => service.name).join(', ') || 'General service incident'} · Started ${dateTime.format(new Date(incident.startedAt))}`
: 'Public incident report and service restoration updates.';
useSeo({
title: incident
? `${incident.title}${incident.status} — upwatch status`
: query.isError
? 'Incident not found — upwatch'
: 'Incident report — upwatch',
description: latestUpdate ?? fallbackDescription,
noindex: query.isError,
canonicalPath: `/incidents/${id}`,
});
return (
<div className="status-page-shell">
<header className="dashboard-header status-header">
+2
View File
@@ -4,12 +4,14 @@ import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { navigate } from '../lib/router';
import { useSeo } from '../lib/seo';
import { useLoginMutation, useSessionQuery } from '../queries/auth';
export function LoginPage() {
const [password, setPassword] = useState('');
const sessionQuery = useSessionQuery();
const loginMutation = useLoginMutation();
useSeo({ title: 'Sign in — upwatch', noindex: true });
useEffect(() => {
if (sessionQuery.data?.authenticated) navigate('/dashboard', { replace: true });
+2
View File
@@ -13,6 +13,7 @@ import { INTERVAL_OPTIONS, MonitorFormDialog } from '../components/dashboard/Mon
import { formatDate, formatDuration } from '../lib/format';
import { monitorState } from '../lib/monitor-status';
import { navigate } from '../lib/router';
import { useSeo } from '../lib/seo';
import {
useDeleteMonitorMutation,
useMonitorChecksQuery,
@@ -40,6 +41,7 @@ export function MonitorDetailPage({ id }: { id: number }) {
const checks = checksQuery.data?.checks ?? [];
const incidents = incidentsQuery.data?.incidents ?? [];
const openIncident = incidents.find((incident) => incident.resolvedAt === null);
useSeo({ title: monitor ? `${monitor.name} — upwatch` : 'Monitor — upwatch', noindex: true });
if (monitorQuery.isPending)
return (
+2
View File
@@ -11,6 +11,7 @@ import { AiActivityPanel } from '../components/settings/AiActivityPanel';
import { MaintenanceWindowsPanel } from '../components/settings/MaintenanceWindowsPanel';
import { NotificationChannelsPanel } from '../components/settings/NotificationChannelsPanel';
import { navigate } from '../lib/router';
import { useSeo } from '../lib/seo';
import { useAiSettingsQuery, useTestAiSettingsMutation, useUpdateAiSettingsMutation } from '../queries/settings';
function AiSettingsForm({ settings }: { settings: AiSettings }) {
@@ -149,6 +150,7 @@ function AiSettingsForm({ settings }: { settings: AiSettings }) {
export function SettingsPage() {
const aiSettingsQuery = useAiSettingsQuery();
useSeo({ title: 'Settings — upwatch', noindex: true });
return (
<div className="dashboard-shell">
<AppHeader context="Settings" />
+11
View File
@@ -8,6 +8,7 @@ import { SiteIcon } from '../components/SiteIcon';
import { StatusHistoryBar } from '../components/StatusHistoryBar';
import { formatDate, formatDuration } from '../lib/format';
import { navigate } from '../lib/router';
import { useSeo } from '../lib/seo';
import { useSessionQuery } from '../queries/auth';
import { useIncidentHistoryQuery, useStatusQuery } from '../queries/status';
@@ -71,6 +72,16 @@ export function StatusPage() {
const activeIncidents = status?.activeIncidents ?? [];
const maintenanceServices = status?.services.filter((service) => service.maintenance) ?? [];
const pastIncidents = historyQuery.data?.incidents ?? [];
const operationalServices = status?.services.filter((service) => service.status === 'up').length ?? 0;
const affectedServices = (status?.services.length ?? 0) - operationalServices;
const description = status
? `${status.services.length} ${status.services.length === 1 ? 'service' : 'services'} · ${operationalServices} operational, ${affectedServices} affected · ${activeIncidents.length} active ${activeIncidents.length === 1 ? 'incident' : 'incidents'}`
: 'Live operational health and 90-day availability for every public service.';
useSeo({
title: status ? `${OVERALL_COPY[status.overall].title} — upwatch status` : 'Service status — upwatch',
description,
canonicalPath: '/',
});
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 5_000);
+6
View File
@@ -7,6 +7,7 @@ import channelRoutes from './routes/channels';
import incidentRoutes from './routes/incidents';
import maintenanceRoutes from './routes/maintenance';
import monitorRoutes from './routes/monitors';
import { createPageRoutes } from './routes/pages';
import settingsRoutes from './routes/settings';
import statusRoutes from './routes/status';
import { cleanupExpiredAuthRecords, cleanupStaleData } from './scheduled/cleanup';
@@ -16,6 +17,11 @@ const app = new Hono<{ Bindings: Env }>();
app.use('/api/*', csrf());
app.route(
'/',
createPageRoutes((request, env, executionCtx) => app.fetch(request, env, executionCtx)),
);
app.get('/api/health', async (context) => {
const db = await context.env.DB.prepare('SELECT 1 AS ok').first<{
ok: number;
+132
View File
@@ -0,0 +1,132 @@
import { Hono, type Context } from 'hono';
import type { PublicIncident, PublicStatus } from '../../client/api/status';
import { resolveStatusCacheSeconds } from '../lib/runtime-config';
import { rewriteHead } from '../seo/html';
import { absoluteBase, escapeHtml, incidentHead, statusHead } from '../seo/meta';
type AppFetch = (request: Request, env: Env, executionCtx: ExecutionContext) => Response | Promise<Response>;
type EdgeCache = {
match(request: RequestInfo | URL): Promise<Response | undefined>;
put(request: RequestInfo | URL, response: Response): Promise<void>;
};
function edgeCache(): EdgeCache | null {
try {
return (caches as CacheStorage & { readonly default: EdgeCache }).default;
} catch {
return null;
}
}
function cacheKey(context: Context<{ Bindings: Env }>): Request {
const url = new URL(context.req.url);
return new Request(`${url.origin}${url.pathname}`);
}
async function cachedPage(context: Context<{ Bindings: Env }>): Promise<Response | undefined> {
if (resolveStatusCacheSeconds(context.env) <= 0) return undefined;
try {
return await edgeCache()?.match(cacheKey(context));
} catch {
return undefined;
}
}
function cacheResponse(context: Context<{ Bindings: Env }>, response: Response, seconds: number): Response {
const headers = new Headers(response.headers);
headers.set('Cache-Control', `public, max-age=${seconds}`);
const cacheable = new Response(response.body, { status: response.status, statusText: response.statusText, headers });
const cache = edgeCache();
if (cache && seconds > 0) {
try {
context.executionCtx.waitUntil(cache.put(cacheKey(context), cacheable.clone()).catch(() => undefined));
} catch {
// Unit tests may not expose a request execution context.
}
}
return cacheable;
}
async function assetShell(context: Context<{ Bindings: Env }>): Promise<Response> {
const requestUrl = new URL('/index.html', context.req.url);
return context.env.ASSETS.fetch(new Request(requestUrl, { headers: context.req.raw.headers }));
}
async function internalJson<T>(context: Context<{ Bindings: Env }>, fetchApp: AppFetch, pathname: string): Promise<T | null> {
try {
const request = new Request(new URL(pathname, context.req.url), { headers: { Accept: 'application/json' } });
const response = await fetchApp(request, context.env, context.executionCtx);
return response.ok ? await response.json<T>() : null;
} catch {
return null;
}
}
export function createPageRoutes(fetchApp: AppFetch) {
const routes = new Hono<{ Bindings: Env }>();
routes.get('/', async (context) => {
const cached = await cachedPage(context);
if (cached) return cached;
const payload = await internalJson<PublicStatus>(context, fetchApp, '/api/status');
const transformed = rewriteHead(await assetShell(context), statusHead(payload, absoluteBase(context.env, context.req.raw)));
return cacheResponse(context, transformed, resolveStatusCacheSeconds(context.env));
});
routes.get('/incidents/:id', async (context) => {
const cached = await cachedPage(context);
if (cached) return cached;
const id = context.req.param('id');
const payload = await internalJson<{ incident: PublicIncident }>(context, fetchApp, `/api/status/incidents/${encodeURIComponent(id)}`);
const transformed = rewriteHead(
await assetShell(context),
incidentHead(payload?.incident ?? null, absoluteBase(context.env, context.req.raw), id),
);
return cacheResponse(context, transformed, resolveStatusCacheSeconds(context.env));
});
routes.get('/robots.txt', (context) => {
const base = absoluteBase(context.env, context.req.raw);
return context.text(
[
'User-agent: *',
'Allow: /',
'Allow: /incidents/',
'Disallow: /api/',
'Disallow: /dashboard',
'Disallow: /settings',
'Disallow: /monitors/',
'Disallow: /login',
`Sitemap: ${base}/sitemap.xml`,
'',
].join('\n'),
200,
{ 'Cache-Control': 'public, max-age=3600' },
);
});
routes.get('/sitemap.xml', async (context) => {
const base = absoluteBase(context.env, context.req.raw);
const payload = await internalJson<{ incidents: PublicIncident[] }>(context, fetchApp, '/api/status/incidents');
const urls = [
`<url><loc>${escapeHtml(`${base}/`)}</loc><changefreq>hourly</changefreq><priority>1.0</priority></url>`,
...(payload?.incidents ?? []).map((incident) => {
const lastModified = incident.resolvedAt ?? incident.startedAt;
return `<url><loc>${escapeHtml(`${base}/incidents/${incident.id}`)}</loc><lastmod>${escapeHtml(new Date(lastModified).toISOString())}</lastmod><priority>0.6</priority></url>`;
}),
];
return context.body(
`<?xml version="1.0" encoding="UTF-8"?><urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">${urls.join('')}</urlset>`,
200,
{
'Content-Type': 'application/xml; charset=UTF-8',
'Cache-Control': 'public, max-age=10800',
},
);
});
routes.all('/', (context) => context.env.ASSETS.fetch(context.req.raw));
routes.all('/incidents/*', (context) => context.env.ASSETS.fetch(context.req.raw));
return routes;
}
+36
View File
@@ -0,0 +1,36 @@
import { renderMetaTags, type SeoHead } from './meta';
class RemoveElementHandler implements HTMLRewriterElementContentHandlers {
element(element: Element) {
element.remove();
}
}
class TitleHandler implements HTMLRewriterElementContentHandlers {
constructor(private readonly title: string) {}
element(element: Element) {
element.setInnerContent(this.title);
}
}
class HeadHandler implements HTMLRewriterElementContentHandlers {
constructor(private readonly head: SeoHead) {}
element(element: Element) {
element.append(renderMetaTags(this.head), { html: true });
}
}
export function rewriteHead(assetResponse: Response, head: SeoHead): Response {
const remove = new RemoveElementHandler();
return new HTMLRewriter()
.on('title', new TitleHandler(head.title))
.on('meta[name="description"]', remove)
.on('meta[name="robots"]', remove)
.on('meta[property^="og:"]', remove)
.on('meta[name^="twitter:"]', remove)
.on('link[rel="canonical"]', remove)
.on('head', new HeadHandler(head))
.transform(assetResponse);
}
+91
View File
@@ -0,0 +1,91 @@
import type { PublicIncident, PublicStatus } from '../../client/api/status';
export type SeoHead = {
title: string;
description: string;
canonical: string;
image: string;
robots: string;
};
const STATUS_TITLES: Record<PublicStatus['overall'], string> = {
operational: 'All systems operational',
degraded: 'Some systems are degraded',
down: 'Major service disruption',
};
export function absoluteBase(env: Env, request: Request): string {
const configured = (env as unknown as { PUBLIC_BASE_URL?: string }).PUBLIC_BASE_URL?.trim().replace(/\/$/, '');
return configured || new URL(request.url).origin;
}
export function escapeHtml(value: string): string {
return value.replace(/[&<>"']/g, (character) => {
if (character === '&') return '&amp;';
if (character === '<') return '&lt;';
if (character === '>') return '&gt;';
if (character === '"') return '&quot;';
return '&#39;';
});
}
function cleanDescription(value: string, fallback: string): string {
const normalized = value.replace(/\s+/g, ' ').trim();
if (!normalized) return fallback;
return normalized.length > 200 ? `${normalized.slice(0, 197).trimEnd()}` : normalized;
}
export function statusHead(status: PublicStatus | null, base: string): SeoHead {
const title = status ? `${STATUS_TITLES[status.overall]} — upwatch status` : 'Service status — upwatch';
const description = status
? `${status.services.length} ${status.services.length === 1 ? 'service' : 'services'} · ${status.services.filter((service) => service.status === 'up').length} operational · ${status.activeIncidents.length} active ${status.activeIncidents.length === 1 ? 'incident' : 'incidents'}`
: 'Live operational health and 90-day availability for every public service.';
return { title, description, canonical: `${base}/`, image: `${base}/og.png`, robots: 'index,follow' };
}
export function incidentHead(incident: PublicIncident | null, base: string, id: string): SeoHead {
if (!incident) {
return {
title: 'Incident not found — upwatch status',
description: 'The requested public incident report could not be found.',
canonical: `${base}/incidents/${encodeURIComponent(id)}`,
image: `${base}/og.png`,
robots: 'noindex,follow',
};
}
const latestUpdate = incident.updates?.at(-1)?.body;
const services = incident.services.map((service) => service.name).join(', ');
const fallback = `${services || 'General service incident'} · Started ${new Date(incident.startedAt).toISOString()}`;
return {
title: `${incident.title}${incident.status} — upwatch status`,
description: cleanDescription(latestUpdate ?? '', fallback),
canonical: `${base}/incidents/${incident.id}`,
image: `${base}/og.png`,
robots: 'index,follow',
};
}
export function renderMetaTags(head: SeoHead): string {
const title = escapeHtml(head.title);
const description = escapeHtml(head.description);
const canonical = escapeHtml(head.canonical);
const image = escapeHtml(head.image);
const robots = escapeHtml(head.robots);
return [
`<meta name="description" content="${description}">`,
`<meta name="robots" content="${robots}">`,
`<link rel="canonical" href="${canonical}">`,
'<meta property="og:type" content="website">',
'<meta property="og:site_name" content="Upwatch">',
`<meta property="og:title" content="${title}">`,
`<meta property="og:description" content="${description}">`,
`<meta property="og:url" content="${canonical}">`,
`<meta property="og:image" content="${image}">`,
'<meta property="og:image:width" content="1200">',
'<meta property="og:image:height" content="630">',
'<meta name="twitter:card" content="summary_large_image">',
`<meta name="twitter:title" content="${title}">`,
`<meta name="twitter:description" content="${description}">`,
`<meta name="twitter:image" content="${image}">`,
].join('');
}