diff --git a/src/client/components/SiteIcon.tsx b/src/client/components/SiteIcon.tsx
new file mode 100644
index 0000000..6e448f2
--- /dev/null
+++ b/src/client/components/SiteIcon.tsx
@@ -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 ;
+
+ return (
+
setFailed(true)}
+ />
+ );
+}
diff --git a/src/client/pages/DashboardPage.tsx b/src/client/pages/DashboardPage.tsx
index e5df657..09b68bc 100644
--- a/src/client/pages/DashboardPage.tsx
+++ b/src/client/pages/DashboardPage.tsx
@@ -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 (
- {monitor.url}{monitor.method} · expect {monitor.expectedStatus} · every {monitor.intervalSeconds / 60}m
+ {monitor.url}{monitor.method} · expect {monitor.expectedStatus} · every {monitor.intervalSeconds / 60}m
{checking ? "Checking" : status.label}{monitor.lastStatusCode === null ? "—" : `HTTP ${monitor.lastStatusCode}`} · {monitor.lastLatencyMs === null ? "—" : `${monitor.lastLatencyMs} ms`}{monitor.lastError ?? formatCheckedAt(monitor.lastCheckedAt)}
diff --git a/src/client/pages/MonitorDetailPage.tsx b/src/client/pages/MonitorDetailPage.tsx
index a136a2f..d85c188 100644
--- a/src/client/pages/MonitorDetailPage.tsx
+++ b/src/client/pages/MonitorDetailPage.tsx
@@ -74,7 +74,7 @@ export function MonitorDetailPage({ id }: { id: number }) {
-
Event stream
Recent checks
| Status | Response | Latency | Checked |
{checks.slice(0, 20).map((check) => | {check.ok ? "Up" : "Down"} | {check.statusCode ? `HTTP ${check.statusCode}` : check.error ?? "Failed"} | {check.latencyMs} ms | {formatDate(check.checkedAt)} |
)}
{checks.length === 0 &&
No checks recorded.
}
+
Event stream
Recent checks
{Math.min(checks.length, 20)} shown| Status | Response | Latency | Checked |
{checks.slice(0, 20).map((check) => | {check.ok ? "Up" : "Down"} | {check.statusCode ? `HTTP ${check.statusCode}` : check.error ?? "Failed"} | {check.latencyMs} ms | {formatDate(check.checkedAt)} |
)}
{checks.length === 0 &&
No checks recorded.
}
{incidents.length} recorded {incidents.map((incident) =>
{incident.resolvedAt ? : }{incident.resolvedAt ? "Resolved incident" : "Incident in progress"}{incident.startError ?? (incident.startStatusCode ? `HTTP ${incident.startStatusCode}` : "Endpoint became unavailable")}
{formatDate(incident.startedAt)} · {formatDuration(incident.durationMs, incident.startedAt)} )}{incidents.length === 0 &&
No downtime incidents recorded.
}
diff --git a/src/client/styles.css b/src/client/styles.css
index e3988f6..7259d69 100644
--- a/src/client/styles.css
+++ b/src/client/styles.css
@@ -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; }
diff --git a/src/worker/routes/monitors.ts b/src/worker/routes/monitors.ts
index 27b78ac..f7cd90a 100644
--- a/src/worker/routes/monitors.ts
+++ b/src/worker/routes/monitors.ts
@@ -25,6 +25,218 @@ type ParseResult =
| { ok: false; message: string };
const METHODS = new Set(["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;
+ put(request: RequestInfo | URL, response: Response): Promise;
+};
+
+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(/]*>/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(/]*>/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 {
+ 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 {
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 {
diff --git a/test/monitors.spec.ts b/test/monitors.spec.ts
index 81c1862..054490b 100644
--- a/test/monitors.spec.ts
+++ b/test/monitors.spec.ts
@@ -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('', {
+ 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);