mirror of
https://github.com/Nezumi-2711/uptime-monitoring.git
synced 2026-09-22 13:48:31 +00:00
feat: improve SEO feature
This commit is contained in:
@@ -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]);
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 '&';
|
||||
if (character === '<') return '<';
|
||||
if (character === '>') return '>';
|
||||
if (character === '"') return '"';
|
||||
return ''';
|
||||
});
|
||||
}
|
||||
|
||||
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('');
|
||||
}
|
||||
Reference in New Issue
Block a user