feat(monitoring): add stats counters API and public status page

Add GET /api/stats exposing cumulative counters (feeds created/deleted,
emails received/rejected, recent date-times) plus live values (active
feeds, active WebSub subscriptions). Counters persist in a stats:counters
KV singleton and are incremented at the email-processing chokepoint and
feed create/delete paths. Replace the / → /admin redirect with a public
status page rendering these figures with a link to the admin.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Julien Herr
2026-05-23 09:50:51 +02:00
parent f4d5edda0e
commit b534ce5bf8
15 changed files with 484 additions and 6 deletions
+16 -1
View File
@@ -2,6 +2,7 @@ import { Hono } from "hono";
import { z } from "zod";
import { Env, FeedConfig, FeedMetadata } from "../../types";
import { generateFeedId } from "../../utils/id-generator";
import { bumpCounters } from "../../utils/stats";
import { waitUntilSafe } from "../../utils/worker";
import { feedRssUrl, feedEmailAddress } from "../../utils/urls";
import { logger } from "../../lib/logger";
@@ -191,6 +192,11 @@ feedsRouter.post("/create", async (c) => {
expiresAt,
);
await bumpCounters(emailStorage, {
feeds_created: 1,
last_feed_created_at: new Date().toISOString(),
});
if (isJson) {
return c.json({
feedId,
@@ -528,7 +534,10 @@ feedsRouter.post("/:feedId/delete", async (c) => {
try {
await deleteFeedFast(emailStorage, feedId);
await removeFeedFromList(emailStorage, feedId);
const removed = await removeFeedFromList(emailStorage, feedId);
if (removed) {
await bumpCounters(emailStorage, { feeds_deleted: 1 });
}
waitUntilSafe(
c,
@@ -658,6 +667,9 @@ feedsRouter.post("/bulk-delete", async (c) => {
}
const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds);
if (deletedFeedIds.length > 0) {
await bumpCounters(emailStorage, { feeds_deleted: deletedFeedIds.length });
}
const removed = new Set(deletedFeedIds);
okIds.forEach((feedId) => {
@@ -707,6 +719,9 @@ feedsRouter.post("/bulk-delete", async (c) => {
}
const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds);
if (deletedFeedIds.length > 0) {
await bumpCounters(emailStorage, { feeds_deleted: deletedFeedIds.length });
}
return c.redirect(
`${redirectBase}&message=bulkDeleted&count=${deletedFeedIds.length}`,
+3 -2
View File
@@ -8,10 +8,11 @@ const designSystem = [variablesCss, layoutCss, componentsCss, utilitiesCss].join
type LayoutProps = {
title: string;
label?: string;
children: import("hono/jsx").Child;
};
export const Layout = ({ title, children }: LayoutProps) => {
export const Layout = ({ title, label = "admin", children }: LayoutProps) => {
return (
<html>
<head>
@@ -38,7 +39,7 @@ export const Layout = ({ title, children }: LayoutProps) => {
<a href="https://kill-the.news/" class="site-header-logo" target="_blank" rel="noopener">
kill-the-news
</a>
<span class="site-header-label">admin</span>
<span class="site-header-label">{label}</span>
</header>
{children}
<footer class="site-footer">
+57
View File
@@ -0,0 +1,57 @@
import { Context } from "hono";
import { Env } from "../types";
import { getStats } from "../utils/stats";
import { Layout } from "./admin/ui";
function formatDateTime(iso?: string): string {
if (!iso) return "Never";
const date = new Date(iso);
if (Number.isNaN(date.getTime())) return "Never";
return date.toISOString().replace("T", " ").replace(/\.\d+Z$/, " UTC");
}
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
const stats = await getStats(c.env);
const cards: Array<{ label: string; value: string | number; time?: boolean }> =
[
{ label: "Active feeds", value: stats.active_feeds },
{ label: "Feeds created", value: stats.feeds_created },
{ label: "Feeds deleted", value: stats.feeds_deleted },
{ label: "Emails received", value: stats.emails_received },
{ label: "Emails rejected", value: stats.emails_rejected },
{ label: "WebSub subscribers", value: stats.websub_subscriptions_active },
{ label: "Last email", value: formatDateTime(stats.last_email_at), time: true },
{ label: "Last feed created", value: formatDateTime(stats.last_feed_created_at), time: true },
{ label: "Online since", value: formatDateTime(stats.first_seen), time: true },
];
return c.html(
<Layout title="Status" label="status">
<div class="container fade-in">
<div class="header-with-actions">
<div class="header-title">
<h1>kill-the-news</h1>
<p>Instance status &amp; monitoring</p>
</div>
<div class="header-actions">
<a href="/admin" class="button">
Go to admin
</a>
</div>
</div>
<div class="stats-grid">
{cards.map((card) => (
<div class="card stat-card">
<span class={`stat-value${card.time ? " stat-value-time" : ""}`}>
{card.value}
</span>
<span class="stat-label">{card.label}</span>
</div>
))}
</div>
</div>
</Layout>,
);
}
+66
View File
@@ -0,0 +1,66 @@
import { describe, it, expect } from "vitest";
import worker from "../index";
import { createMockEnv } from "../test/setup";
import { bumpCounters } from "../utils/stats";
import { FEEDS_LIST_KEY } from "../config/constants";
import type { Env, StatsResponse } from "../types";
function req(path: string, init: RequestInit = {}): Request {
return new Request(`https://test.getmynews.app${path}`, init);
}
describe("GET /api/stats", () => {
it("returns zeroed stats for a fresh instance", async () => {
const env = createMockEnv() as unknown as Env;
const res = await worker.fetch(req("/api/stats"), env);
expect(res.status).toBe(200);
const body = (await res.json()) as StatsResponse;
expect(body).toMatchObject({
feeds_created: 0,
feeds_deleted: 0,
emails_received: 0,
emails_rejected: 0,
active_feeds: 0,
websub_subscriptions_active: 0,
});
});
it("reflects persisted counters and live values", async () => {
const env = createMockEnv() as unknown as Env;
await env.EMAIL_STORAGE.put(
FEEDS_LIST_KEY,
JSON.stringify({ feeds: [{ id: "a", title: "A" }] }),
);
await env.EMAIL_STORAGE.put("websub:a:hash", "{}");
await bumpCounters(env.EMAIL_STORAGE, {
emails_received: 3,
emails_rejected: 1,
feeds_created: 1,
});
const res = await worker.fetch(req("/api/stats"), env);
const body = (await res.json()) as StatsResponse;
expect(body.active_feeds).toBe(1);
expect(body.websub_subscriptions_active).toBe(1);
expect(body.emails_received).toBe(3);
expect(body.emails_rejected).toBe(1);
expect(body.feeds_created).toBe(1);
});
});
describe("GET / (public status page)", () => {
it("returns an HTML status page with counters and an admin link", async () => {
const env = createMockEnv() as unknown as Env;
await bumpCounters(env.EMAIL_STORAGE, { emails_received: 7 });
const res = await worker.fetch(req("/"), env);
expect(res.status).toBe(200);
expect(res.headers.get("Content-Type")).toContain("text/html");
const html = await res.text();
expect(html).toContain('href="/admin"');
expect(html).toContain("Active feeds");
expect(html).toContain("Emails received");
expect(html).toContain("7");
});
});
+7
View File
@@ -0,0 +1,7 @@
import { Context } from "hono";
import { Env } from "../types";
import { getStats } from "../utils/stats";
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
return c.json(await getStats(c.env));
}