feat: show icon page instead of mock

This commit is contained in:
2026-08-28 15:54:34 +07:00
parent 2df694212a
commit 05922d7e03
6 changed files with 341 additions and 3 deletions
+23
View File
@@ -0,0 +1,23 @@
import { Database } from "lucide-react";
import { useState } from "react";
type SiteIconProps = {
monitorId: number;
};
export function SiteIcon({ monitorId }: SiteIconProps) {
const [failed, setFailed] = useState(false);
if (failed) return <Database aria-hidden="true" />;
return (
<img
src={`/api/monitors/${monitorId}/favicon`}
alt=""
width={22}
height={22}
loading="lazy"
onError={() => setFailed(true)}
/>
);
}
+2 -1
View File
@@ -1,6 +1,7 @@
import { type FormEvent, useState } from "react";
import { ArrowRight, Database, RefreshCw, Zap } from "lucide-react";
import type { Monitor, MonitorInput, MonitorMethod } from "../api/monitors";
import { SiteIcon } from "../components/SiteIcon";
import { useLogoutMutation } from "../queries/auth";
import { navigate } from "../lib/router";
import {
@@ -186,7 +187,7 @@ export function DashboardPage() {
const toggling = updateMutation.isPending && updateMutation.variables?.id === monitor.id;
return (
<article className={`service-row ${monitor.enabled ? "" : "is-disabled"}`} key={monitor.id}>
<div className="service-name"><span className="service-icon"><Database /></span><div><button className="monitor-name-link" type="button" onClick={() => navigate(`/monitors/${monitor.id}`)}>{monitor.name}</button><small title={monitor.url}>{monitor.url}</small><span className="monitor-meta">{monitor.method} · expect {monitor.expectedStatus} · every {monitor.intervalSeconds / 60}m</span></div></div>
<div className="service-name"><span className="service-icon"><SiteIcon key={monitor.url} monitorId={monitor.id} /></span><div><button className="monitor-name-link" type="button" onClick={() => navigate(`/monitors/${monitor.id}`)}>{monitor.name}</button><small title={monitor.url}>{monitor.url}</small><span className="monitor-meta">{monitor.method} · expect {monitor.expectedStatus} · every {monitor.intervalSeconds / 60}m</span></div></div>
<div className="monitor-result"><span className={`row-status ${checking ? "checking" : status.className}`}><i />{checking ? "Checking" : status.label}</span><code>{monitor.lastStatusCode === null ? "—" : `HTTP ${monitor.lastStatusCode}`} · {monitor.lastLatencyMs === null ? "—" : `${monitor.lastLatencyMs} ms`}</code><small title={monitor.lastError ?? undefined}>{monitor.lastError ?? formatCheckedAt(monitor.lastCheckedAt)}</small></div>
<div className="row-actions"><button type="button" onClick={() => navigate(`/monitors/${monitor.id}`)}>History</button><button type="button" onClick={() => checkMutation.mutate(monitor.id)} disabled={checking}>Check now</button><button type="button" onClick={() => openEditForm(monitor)}>Edit</button><button type="button" onClick={() => updateMutation.mutate({ id: monitor.id, input: { enabled: !monitor.enabled } })} disabled={toggling}>{monitor.enabled ? "Disable" : "Enable"}</button><button className="danger-action" type="button" onClick={() => handleDelete(monitor)} disabled={deleting}>{deleting ? "Deleting…" : "Delete"}</button></div>
</article>
+1 -1
View File
@@ -74,7 +74,7 @@ export function MonitorDetailPage({ id }: { id: number }) {
</div>
<div className="detail-grid lower-grid">
<section className="data-panel"><div className="data-panel-heading"><div><p className="overline">Event stream</p><h2>Recent checks</h2></div></div><div className="data-table-wrap"><table className="data-table"><thead><tr><th>Status</th><th>Response</th><th>Latency</th><th>Checked</th></tr></thead><tbody>{checks.slice(0, 20).map((check) => <tr key={check.id}><td><span className={`row-status ${check.ok ? "online" : "offline"}`}><i />{check.ok ? "Up" : "Down"}</span></td><td><code>{check.statusCode ? `HTTP ${check.statusCode}` : check.error ?? "Failed"}</code></td><td>{check.latencyMs} ms</td><td>{formatDate(check.checkedAt)}</td></tr>)}</tbody></table>{checks.length === 0 && <div className="table-empty">No checks recorded.</div>}</div></section>
<section className="data-panel recent-checks-panel"><div className="data-panel-heading"><div><p className="overline">Event stream</p><h2>Recent checks</h2></div><span>{Math.min(checks.length, 20)} shown</span></div><div className="data-table-wrap recent-checks-scroll" role="region" aria-label="Recent checks" tabIndex={0}><table className="data-table"><thead><tr><th>Status</th><th>Response</th><th>Latency</th><th>Checked</th></tr></thead><tbody>{checks.slice(0, 20).map((check) => <tr key={check.id}><td><span className={`row-status ${check.ok ? "online" : "offline"}`}><i />{check.ok ? "Up" : "Down"}</span></td><td><code>{check.statusCode ? `HTTP ${check.statusCode}` : check.error ?? "Failed"}</code></td><td>{check.latencyMs} ms</td><td>{formatDate(check.checkedAt)}</td></tr>)}</tbody></table>{checks.length === 0 && <div className="table-empty">No checks recorded.</div>}</div></section>
<section className="data-panel"><div className="data-panel-heading"><div><p className="overline">Downtime</p><h2>Incidents</h2></div><span>{incidents.length} recorded</span></div><div className="incident-list">{incidents.map((incident) => <article className={incident.resolvedAt ? "resolved" : "open"} key={incident.id}><span>{incident.resolvedAt ? <CheckCircle2 /> : <Clock3 />}</span><div><strong>{incident.resolvedAt ? "Resolved incident" : "Incident in progress"}</strong><p>{incident.startError ?? (incident.startStatusCode ? `HTTP ${incident.startStatusCode}` : "Endpoint became unavailable")}</p><small>{formatDate(incident.startedAt)} · {formatDuration(incident.durationMs, incident.startedAt)}</small></div></article>)}{incidents.length === 0 && <div className="table-empty">No downtime incidents recorded.</div>}</div></section>
</div>
</main>
+10
View File
@@ -105,6 +105,7 @@ button { color: inherit; }
.service-name > div { min-width: 0; }
.service-icon { display: grid; place-items: center; flex: 0 0 auto; width: 40px; height: 40px; border: 1px solid #dedede; border-radius: 6px; color: #555; background: #fafafa; }
.service-icon svg { width: 19px; height: 19px; }
.service-icon img { width: 22px; height: 22px; border-radius: 4px; object-fit: contain; }
.service-name strong, .service-name small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.service-name strong { font-size: 15px; font-weight: 500; }
.monitor-name-link { display: block; max-width: 100%; padding: 0; overflow: hidden; border: 0; font-size: 15px; font-weight: 500; text-align: left; text-overflow: ellipsis; white-space: nowrap; background: transparent; cursor: pointer; }
@@ -222,6 +223,15 @@ button { color: inherit; }
.uptime-legend i { width: 6px; height: 6px; border-radius: 50%; background: var(--primary-deep); }
.uptime-legend i.legend-down { margin-left: 5px; background: #d95c5c; }
.data-table-wrap { overflow-x: auto; }
.recent-checks-panel { display: flex; min-height: 0; flex-direction: column; }
.recent-checks-scroll { max-height: clamp(320px, 52vh, 556px); overflow: auto; overscroll-behavior: contain; scrollbar-color: #b8c7c0 #f4f6f5; scrollbar-gutter: stable; scrollbar-width: thin; }
.recent-checks-scroll:focus-visible { outline: 2px solid rgb(36 180 126 / 0.45); outline-offset: -2px; }
.recent-checks-scroll::-webkit-scrollbar { width: 9px; height: 9px; }
.recent-checks-scroll::-webkit-scrollbar-track { background: #f4f6f5; }
.recent-checks-scroll::-webkit-scrollbar-thumb { border: 2px solid #f4f6f5; border-radius: 999px; background: #b8c7c0; }
.recent-checks-scroll::-webkit-scrollbar-thumb:hover { background: #91a69d; }
.recent-checks-scroll .data-table { min-width: 600px; }
.recent-checks-scroll .data-table th { position: sticky; z-index: 1; top: 0; box-shadow: 0 1px #e5e5e5; }
.data-table { width: 100%; border-collapse: collapse; font-size: 12px; }
.data-table th { padding: 11px 16px; font-weight: 400; text-align: left; color: #888; background: #fafafa; }
.data-table td { padding: 14px 16px; border-top: 1px solid #ededed; color: #555; white-space: nowrap; }
+250
View File
@@ -25,6 +25,218 @@ type ParseResult =
| { ok: false; message: string };
const METHODS = new Set<MonitorMethod>(["GET", "HEAD", "POST"]);
const FAVICON_CACHE_SECONDS = 86_400;
const FAVICON_FETCH_TIMEOUT_MS = 5_000;
const MAX_FAVICON_BYTES = 1024 * 1024;
const MAX_HEAD_BYTES = 128 * 1024;
const MAX_REDIRECTS = 3;
type FaviconResult = {
body: ArrayBuffer;
contentType: string;
};
type EdgeCache = {
match(request: RequestInfo | URL): Promise<Response | undefined>;
put(request: RequestInfo | URL, response: Response): Promise<void>;
};
function isPrivateHostname(rawHostname: string): boolean {
const hostname = rawHostname.toLowerCase().replace(/^\[|\]$/g, "").replace(/\.$/, "");
if (hostname === "localhost" || hostname.endsWith(".localhost")) return true;
const ipv4 = hostname.split(".").map(Number);
if (ipv4.length === 4 && ipv4.every((part) => Number.isInteger(part) && part >= 0 && part <= 255)) {
const [first, second] = ipv4;
return first === 0
|| first === 10
|| first === 127
|| (first === 169 && second === 254)
|| (first === 172 && second >= 16 && second <= 31)
|| (first === 192 && second === 168);
}
if (hostname === "::" || hostname === "::1") return true;
if (/^f[cd][0-9a-f]{2}(?::|$)/i.test(hostname) || /^fe[89ab][0-9a-f](?::|$)/i.test(hostname)) return true;
const mappedIpv4 = hostname.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i);
return mappedIpv4 ? isPrivateHostname(mappedIpv4[1]) : false;
}
function isSafeRemoteUrl(url: URL) {
return (url.protocol === "http:" || url.protocol === "https:")
&& !url.username
&& !url.password
&& !isPrivateHostname(url.hostname);
}
async function readBodyLimited(response: Response, maximum: number, truncate: boolean) {
if (!response.body) return null;
const reader = response.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
let exceeded = false;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
const remaining = maximum - total;
if (value.byteLength > remaining) {
if (truncate && remaining > 0) {
chunks.push(value.subarray(0, remaining));
total += remaining;
}
exceeded = true;
await reader.cancel();
break;
}
chunks.push(value);
total += value.byteLength;
}
} catch {
return null;
}
if (exceeded && !truncate) return null;
const body = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.byteLength;
}
return body.buffer;
}
async function fetchRemote(url: URL, maximumBytes: number, truncate = false) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), FAVICON_FETCH_TIMEOUT_MS);
let currentUrl = url;
try {
for (let redirects = 0; redirects <= MAX_REDIRECTS; redirects += 1) {
if (!isSafeRemoteUrl(currentUrl)) return null;
const response = await fetch(currentUrl, {
headers: {
Accept: "image/avif,image/webp,image/png,image/svg+xml,image/*;q=0.8,text/html;q=0.5,*/*;q=0.1",
"User-Agent": "Upwatch Favicon Proxy/1.0",
},
redirect: "manual",
signal: controller.signal,
});
if ([301, 302, 303, 307, 308].includes(response.status)) {
if (response.body) await response.body.cancel().catch(() => undefined);
const location = response.headers.get("Location");
if (!location || redirects === MAX_REDIRECTS) return null;
try {
currentUrl = new URL(location, currentUrl);
} catch {
return null;
}
continue;
}
if (!response.ok) {
if (response.body) await response.body.cancel().catch(() => undefined);
return null;
}
const declaredLength = Number(response.headers.get("Content-Length"));
if (!truncate && Number.isFinite(declaredLength) && declaredLength > maximumBytes) {
if (response.body) await response.body.cancel().catch(() => undefined);
return null;
}
const body = await readBodyLimited(response, maximumBytes, truncate);
if (!body) return null;
return { body, headers: response.headers, url: currentUrl };
}
} catch {
return null;
} finally {
clearTimeout(timeout);
}
return null;
}
function imageContentType(headers: Headers) {
const contentType = headers.get("Content-Type")?.split(";", 1)[0].trim().toLowerCase();
if (!contentType || contentType === "application/octet-stream") return "image/x-icon";
if (!contentType.startsWith("image/")) {
return null;
}
return contentType;
}
function readTagAttribute(tag: string, name: string) {
const attributes = /([^\s=/>]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
for (const match of tag.matchAll(attributes)) {
if (match[1].toLowerCase() === name) return match[2] ?? match[3] ?? match[4] ?? "";
}
return null;
}
function findFaviconUrl(html: string, pageUrl: URL) {
const closingHead = html.search(/<\/head\s*>/i);
const head = closingHead >= 0 ? html.slice(0, closingHead) : html;
let baseUrl = pageUrl;
const baseTag = head.match(/<base\b[^>]*>/i)?.[0];
const baseHref = baseTag ? readTagAttribute(baseTag, "href") : null;
if (baseHref) {
try {
const candidate = new URL(baseHref, pageUrl);
if (isSafeRemoteUrl(candidate)) baseUrl = candidate;
} catch {
// Keep the page URL as the base when the document declares an invalid URL.
}
}
for (const match of head.matchAll(/<link\b[^>]*>/gi)) {
const rel = readTagAttribute(match[0], "rel")?.toLowerCase().split(/\s+/) ?? [];
if (!rel.includes("icon") && !rel.includes("apple-touch-icon")) continue;
const href = readTagAttribute(match[0], "href");
if (!href) continue;
try {
const faviconUrl = new URL(href, baseUrl);
if (isSafeRemoteUrl(faviconUrl)) return faviconUrl;
} catch {
// Try the next icon declaration.
}
}
return null;
}
export async function resolveFavicon(siteUrl: string): Promise<FaviconResult | null> {
let site: URL;
try {
site = new URL(siteUrl);
} catch {
return null;
}
if (!isSafeRemoteUrl(site)) return null;
const origin = new URL(site.origin);
const defaultIcon = await fetchRemote(new URL("/favicon.ico", origin), MAX_FAVICON_BYTES);
if (defaultIcon && defaultIcon.body.byteLength > 0) {
const contentType = imageContentType(defaultIcon.headers);
if (contentType) return { body: defaultIcon.body, contentType };
}
const page = await fetchRemote(origin, MAX_HEAD_BYTES, true);
if (!page || page.body.byteLength === 0) return null;
const pageContentType = page.headers.get("Content-Type")?.toLowerCase();
if (pageContentType && !pageContentType.includes("text/html") && !pageContentType.includes("application/xhtml+xml")) {
return null;
}
const faviconUrl = findFaviconUrl(new TextDecoder().decode(page.body), page.url);
if (!faviconUrl) return null;
const icon = await fetchRemote(faviconUrl, MAX_FAVICON_BYTES);
if (!icon || icon.body.byteLength === 0) return null;
const contentType = imageContentType(icon.headers);
return contentType ? { body: icon.body, contentType } : null;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -228,6 +440,44 @@ monitorRoutes.get("/:id/stats", async (context) => {
return context.json({ windows: Object.fromEntries(results) as Record<(typeof windows)[number]["key"], StatsWindow> });
});
monitorRoutes.get("/:id/favicon", async (context) => {
const id = parseId(context.req.param("id"));
if (id === null) return context.json({ message: "Monitor not found" }, 404);
const [monitor] = await getDb(context.env)
.select({ url: monitors.url })
.from(monitors)
.where(eq(monitors.id, id))
.limit(1);
if (!monitor) return context.json({ message: "Monitor not found" }, 404);
const cacheKey = new Request(`${new URL(context.req.url).origin}/api/monitors/${id}/favicon`);
let cache: EdgeCache | null = null;
try {
const defaultCache = (caches as CacheStorage & { readonly default: EdgeCache }).default;
const cached = await defaultCache.match(cacheKey);
if (cached) return cached;
cache = defaultCache;
} catch {
// Cache API availability is best-effort, particularly in local and preview environments.
}
const favicon = await resolveFavicon(monitor.url);
if (!favicon) return context.json({ message: "No favicon" }, 404);
const response = new Response(favicon.body, {
headers: {
"Cache-Control": `public, max-age=${FAVICON_CACHE_SECONDS}`,
"Content-Length": String(favicon.body.byteLength),
"Content-Type": favicon.contentType,
"X-Content-Type-Options": "nosniff",
},
});
if (cache) {
context.executionCtx.waitUntil(cache.put(cacheKey, response.clone()).catch(() => undefined));
}
return response;
});
monitorRoutes.post("/", async (context) => {
let body: unknown;
try {
+55 -1
View File
@@ -1,6 +1,7 @@
import { applyD1Migrations, env, SELF, type D1Migration } from "cloudflare:test";
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { hashPassword } from "../src/worker/lib/password";
import { resolveFavicon } from "../src/worker/routes/monitors";
const ADMIN_PASSWORD = "correct-horse-battery-staple";
const VALID_MONITOR = {
@@ -64,10 +65,12 @@ describe("monitor API", () => {
await applyD1Migrations(testEnv.DB, testEnv.TEST_MIGRATIONS);
});
beforeEach(seedAdmin);
afterEach(() => vi.unstubAllGlobals());
it("protects every monitor endpoint, including the collection path without a trailing slash", async () => {
const requests = [
apiFetch("/api/monitors"),
apiFetch("/api/monitors/1/favicon"),
apiFetch("/api/monitors", "POST", "", VALID_MONITOR),
apiFetch("/api/monitors/1", "PATCH", "", { name: "Changed" }),
apiFetch("/api/monitors/1", "DELETE"),
@@ -80,6 +83,57 @@ describe("monitor API", () => {
}
});
it("proxies and caches a monitor favicon response", async () => {
const cookie = await authenticatedCookie();
const created = await (await createMonitor(cookie, { url: "https://favicon-route.example.test/health" }))
.json<{ monitor: { id: number } }>();
vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(input.toString());
expect(url.href).toBe("https://favicon-route.example.test/favicon.ico");
return new Response(new Uint8Array([0, 0, 1, 0]), {
headers: { "Content-Type": "image/x-icon" },
});
}));
const response = await apiFetch(`/api/monitors/${created.monitor.id}/favicon`, "GET", cookie);
expect(response.status).toBe(200);
expect(response.headers.get("Content-Type")).toBe("image/x-icon");
expect(response.headers.get("Cache-Control")).toBe("public, max-age=86400");
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
expect(new Uint8Array(await response.arrayBuffer())).toEqual(new Uint8Array([0, 0, 1, 0]));
});
it("discovers a favicon declared in the website head", async () => {
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
const url = new URL(input.toString());
if (url.pathname === "/favicon.ico") return new Response(null, { status: 404 });
if (url.pathname === "/") {
return new Response('<html><head><link rel="apple-touch-icon" href="/assets/icon.png"></head></html>', {
headers: { "Content-Type": "text/html; charset=utf-8" },
});
}
return new Response(new Uint8Array([137, 80, 78, 71]), {
headers: { "Content-Type": "image/png" },
});
});
vi.stubGlobal("fetch", fetchMock);
const favicon = await resolveFavicon("https://favicon-head.example.test/status");
expect(favicon?.contentType).toBe("image/png");
expect(new Uint8Array(favicon?.body ?? new ArrayBuffer(0))).toEqual(new Uint8Array([137, 80, 78, 71]));
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it("does not fetch favicons from private network hosts", async () => {
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
await expect(resolveFavicon("http://127.0.0.1/admin")).resolves.toBeNull();
await expect(resolveFavicon("http://[::1]/admin")).resolves.toBeNull();
await expect(resolveFavicon("http://10.0.0.1/admin")).resolves.toBeNull();
expect(fetchMock).not.toHaveBeenCalled();
});
it("creates a valid monitor and returns it in the list", async () => {
const cookie = await authenticatedCookie();
const response = await createMonitor(cookie);