mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
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:
@@ -32,7 +32,9 @@ Single Cloudflare Worker built with Hono. Routes:
|
|||||||
|
|
||||||
| Method | Path | Purpose |
|
| Method | Path | Purpose |
|
||||||
| ------------------------------------ | ---------------------------------------------------------------------- | ------- |
|
| ------------------------------------ | ---------------------------------------------------------------------- | ------- |
|
||||||
|
| `GET /` | Public status page (monitoring counters + link to admin) |
|
||||||
| `POST /api/inbound` | Webhook from ForwardEmail; IP-allowlisted to their MX sources |
|
| `POST /api/inbound` | Webhook from ForwardEmail; IP-allowlisted to their MX sources |
|
||||||
|
| `GET /api/stats` | Public monitoring counters (JSON) |
|
||||||
| `GET /rss/:feedId` | Public RSS 2.0 feed |
|
| `GET /rss/:feedId` | Public RSS 2.0 feed |
|
||||||
| `GET /atom/:feedId` | Public Atom feed (with WebSub hub header) |
|
| `GET /atom/:feedId` | Public Atom feed (with WebSub hub header) |
|
||||||
| `GET /entries/:feedId/:entryId` | Individual email HTML view |
|
| `GET /entries/:feedId/:entryId` | Individual email HTML view |
|
||||||
@@ -56,6 +58,8 @@ src/
|
|||||||
entries.ts # Single email HTML view
|
entries.ts # Single email HTML view
|
||||||
files.ts # R2 attachment serving
|
files.ts # R2 attachment serving
|
||||||
hub.ts # WebSub hub
|
hub.ts # WebSub hub
|
||||||
|
home.tsx # Public status page (GET /)
|
||||||
|
stats.ts # Monitoring counters API (GET /api/stats)
|
||||||
admin.tsx # Admin UI entrypoint (hono/jsx)
|
admin.tsx # Admin UI entrypoint (hono/jsx)
|
||||||
admin/ # Admin sub-modules
|
admin/ # Admin sub-modules
|
||||||
feeds.tsx # Feeds CRUD UI
|
feeds.tsx # Feeds CRUD UI
|
||||||
@@ -99,6 +103,7 @@ All data lives in the `EMAIL_STORAGE` KV namespace:
|
|||||||
| `feed:<feedId>:metadata` | `{ emails: Array<{ key, subject, receivedAt, size?, attachmentIds? }> }` |
|
| `feed:<feedId>:metadata` | `{ emails: Array<{ key, subject, receivedAt, size?, attachmentIds? }> }` |
|
||||||
| `feed:<feedId>:<timestamp>` | Full `EmailData` |
|
| `feed:<feedId>:<timestamp>` | Full `EmailData` |
|
||||||
| `websub:<feedId>:<callbackHash>` | `WebSubSubscription` |
|
| `websub:<feedId>:<callbackHash>` | `WebSubSubscription` |
|
||||||
|
| `stats:counters` | `Counters` (cumulative monitoring counters singleton) |
|
||||||
|
|
||||||
`src/lib/storage.ts` contains key-builder helpers — use them; don't inline key strings in routes.
|
`src/lib/storage.ts` contains key-builder helpers — use them; don't inline key strings in routes.
|
||||||
|
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ Common path:
|
|||||||
2. The Worker resolves the feed from the recipient address and stores the email in KV.
|
2. The Worker resolves the feed from the recipient address and stores the email in KV.
|
||||||
3. `https://yourdomain.com/rss/:feedId` renders RSS from stored items.
|
3. `https://yourdomain.com/rss/:feedId` renders RSS from stored items.
|
||||||
4. `/admin` provides feed management and email deletion.
|
4. `/admin` provides feed management and email deletion.
|
||||||
|
5. `https://yourdomain.com/` shows a public status page with monitoring counters and a link to the admin.
|
||||||
|
|
||||||
Main routes:
|
Main routes:
|
||||||
|
|
||||||
@@ -50,6 +51,27 @@ Main routes:
|
|||||||
- `src/routes/atom.ts`: Atom feed rendering
|
- `src/routes/atom.ts`: Atom feed rendering
|
||||||
- `src/routes/files.ts`: attachment file serving from R2
|
- `src/routes/files.ts`: attachment file serving from R2
|
||||||
- `src/routes/admin.ts`: admin UI + feed CRUD
|
- `src/routes/admin.ts`: admin UI + feed CRUD
|
||||||
|
- `src/routes/home.tsx`: public status page (`GET /`)
|
||||||
|
- `src/routes/stats.ts`: monitoring counters API (`GET /api/stats`)
|
||||||
|
|
||||||
|
### Monitoring
|
||||||
|
|
||||||
|
`GET /api/stats` returns JSON counters (public, no auth) for uptime/monitoring tools:
|
||||||
|
|
||||||
|
| Field | Meaning |
|
||||||
|
| ----------------------------- | -------------------------------------------------------- |
|
||||||
|
| `active_feeds` | Feeds currently configured (live) |
|
||||||
|
| `feeds_created` | Total feeds ever created (cumulative) |
|
||||||
|
| `feeds_deleted` | Total feeds ever deleted (cumulative) |
|
||||||
|
| `emails_received` | Total emails ingested successfully (cumulative) |
|
||||||
|
| `emails_rejected` | Total emails rejected during validation (cumulative) |
|
||||||
|
| `websub_subscriptions_active` | Active WebSub subscriptions (live) |
|
||||||
|
| `last_email_at` | ISO 8601 date-time of the last ingested email |
|
||||||
|
| `last_feed_created_at` | ISO 8601 date-time of the last feed creation |
|
||||||
|
| `first_seen` | ISO 8601 date-time the instance first recorded a counter |
|
||||||
|
|
||||||
|
The same figures are rendered on the public status page at `GET /`. Cumulative counters
|
||||||
|
are persisted in the `EMAIL_STORAGE` KV under the `stats:counters` key.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|||||||
@@ -21,3 +21,6 @@ export const MAX_LEASE_SECONDS = 30 * 24 * 3600; // 30 days
|
|||||||
|
|
||||||
/** KV key for the global feed list. */
|
/** KV key for the global feed list. */
|
||||||
export const FEEDS_LIST_KEY = "feeds:list";
|
export const FEEDS_LIST_KEY = "feeds:list";
|
||||||
|
|
||||||
|
/** KV key for the monitoring counters singleton. */
|
||||||
|
export const STATS_KEY = "stats:counters";
|
||||||
|
|||||||
+11
-2
@@ -6,6 +6,8 @@ import { handle as handleAtom } from "./routes/atom";
|
|||||||
import { handle as handleAdmin } from "./routes/admin";
|
import { handle as handleAdmin } from "./routes/admin";
|
||||||
import { handle as handleEntry } from "./routes/entries";
|
import { handle as handleEntry } from "./routes/entries";
|
||||||
import { handle as handleFiles } from "./routes/files";
|
import { handle as handleFiles } from "./routes/files";
|
||||||
|
import { handle as handleStats } from "./routes/stats";
|
||||||
|
import { handle as handleHome } from "./routes/home";
|
||||||
import { hubRouter } from "./routes/hub";
|
import { hubRouter } from "./routes/hub";
|
||||||
import { handleCloudflareEmail } from "./lib/cloudflare-email";
|
import { handleCloudflareEmail } from "./lib/cloudflare-email";
|
||||||
import { Env } from "./types";
|
import { Env } from "./types";
|
||||||
@@ -15,6 +17,7 @@ import {
|
|||||||
purgeExpiredFeeds,
|
purgeExpiredFeeds,
|
||||||
removeFeedsFromListBulk,
|
removeFeedsFromListBulk,
|
||||||
} from "./routes/admin/helpers";
|
} from "./routes/admin/helpers";
|
||||||
|
import { bumpCounters } from "./utils/stats";
|
||||||
import { FORWARD_EMAIL_IPS_CACHE_TTL_MS } from "./config/constants";
|
import { FORWARD_EMAIL_IPS_CACHE_TTL_MS } from "./config/constants";
|
||||||
|
|
||||||
type AppEnv = { Bindings: Env };
|
type AppEnv = { Bindings: Env };
|
||||||
@@ -137,6 +140,9 @@ api.use("/inbound", async (c, next) => {
|
|||||||
// API routes (inbound webhook)
|
// API routes (inbound webhook)
|
||||||
api.post("/inbound", handleInbound);
|
api.post("/inbound", handleInbound);
|
||||||
|
|
||||||
|
// Public monitoring stats (JSON)
|
||||||
|
api.get("/stats", handleStats);
|
||||||
|
|
||||||
// RSS feed routes (public)
|
// RSS feed routes (public)
|
||||||
rss.get("/:feedId", handleRSS);
|
rss.get("/:feedId", handleRSS);
|
||||||
|
|
||||||
@@ -164,8 +170,8 @@ app.route("/hub", hubRouter);
|
|||||||
// Health check endpoint for monitoring
|
// Health check endpoint for monitoring
|
||||||
app.get("/health", (c) => c.json({ status: "ok", timestamp: Date.now() }));
|
app.get("/health", (c) => c.json({ status: "ok", timestamp: Date.now() }));
|
||||||
|
|
||||||
// Root path redirects to admin dashboard
|
// Public status page (counters + link to admin)
|
||||||
app.get("/", (c) => c.redirect("/admin"));
|
app.get("/", handleHome);
|
||||||
|
|
||||||
// Catch-all for 404s
|
// Catch-all for 404s
|
||||||
app.all("*", (c) => c.text("Not Found", 404));
|
app.all("*", (c) => c.text("Not Found", 404));
|
||||||
@@ -192,6 +198,9 @@ export default {
|
|||||||
}
|
}
|
||||||
if (expiredIds.length > 0) {
|
if (expiredIds.length > 0) {
|
||||||
await removeFeedsFromListBulk(env.EMAIL_STORAGE, expiredIds);
|
await removeFeedsFromListBulk(env.EMAIL_STORAGE, expiredIds);
|
||||||
|
await bumpCounters(env.EMAIL_STORAGE, {
|
||||||
|
feeds_deleted: expiredIds.length,
|
||||||
|
});
|
||||||
logger.info("Feed TTL cleanup", { deleted: expiredIds.length });
|
logger.info("Feed TTL cleanup", { deleted: expiredIds.length });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
ProcessEmailInput,
|
ProcessEmailInput,
|
||||||
RawAttachment,
|
RawAttachment,
|
||||||
} from "./email-processor";
|
} from "./email-processor";
|
||||||
|
import { getCounters } from "../utils/stats";
|
||||||
|
|
||||||
const VALID_FEED_ID = "apple.mountain.42";
|
const VALID_FEED_ID = "apple.mountain.42";
|
||||||
const VALID_TO = `${VALID_FEED_ID}@test.getmynews.app`;
|
const VALID_TO = `${VALID_FEED_ID}@test.getmynews.app`;
|
||||||
@@ -467,3 +468,31 @@ describe("processEmail — attachments", () => {
|
|||||||
expect(mockR2._has(oldAttachmentId)).toBe(false);
|
expect(mockR2._has(oldAttachmentId)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("processEmail — monitoring counters", () => {
|
||||||
|
it("increments emails_received and sets last_email_at on success", async () => {
|
||||||
|
const env = createMockEnv();
|
||||||
|
await env.EMAIL_STORAGE.put(
|
||||||
|
`feed:${VALID_FEED_ID}:config`,
|
||||||
|
JSON.stringify({}),
|
||||||
|
);
|
||||||
|
|
||||||
|
await processEmail(makeInput(), env as any);
|
||||||
|
|
||||||
|
const counters = await getCounters(env.EMAIL_STORAGE as any);
|
||||||
|
expect(counters.emails_received).toBe(1);
|
||||||
|
expect(counters.emails_rejected).toBe(0);
|
||||||
|
expect(counters.last_email_at).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("increments emails_rejected when validation fails", async () => {
|
||||||
|
const env = createMockEnv();
|
||||||
|
|
||||||
|
// No feed config → 404 rejection
|
||||||
|
await processEmail(makeInput(), env as any);
|
||||||
|
|
||||||
|
const counters = await getCounters(env.EMAIL_STORAGE as any);
|
||||||
|
expect(counters.emails_rejected).toBe(1);
|
||||||
|
expect(counters.emails_received).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
FeedMetadata,
|
FeedMetadata,
|
||||||
} from "../types";
|
} from "../types";
|
||||||
import { notifySubscribers } from "../utils/websub";
|
import { notifySubscribers } from "../utils/websub";
|
||||||
|
import { bumpCounters } from "../utils/stats";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
import { FEED_MAX_BYTES } from "../config/constants";
|
import { FEED_MAX_BYTES } from "../config/constants";
|
||||||
|
|
||||||
@@ -248,8 +249,15 @@ export async function processEmail(
|
|||||||
ctx?: ExecutionContext,
|
ctx?: ExecutionContext,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
const validation = await validateEmail(input, env);
|
const validation = await validateEmail(input, env);
|
||||||
if (!validation.ok) return validation.response;
|
if (!validation.ok) {
|
||||||
|
await bumpCounters(env.EMAIL_STORAGE, { emails_rejected: 1 });
|
||||||
|
return validation.response;
|
||||||
|
}
|
||||||
|
|
||||||
await storeEmail(validation.feedId, input, env, ctx);
|
await storeEmail(validation.feedId, input, env, ctx);
|
||||||
|
await bumpCounters(env.EMAIL_STORAGE, {
|
||||||
|
emails_received: 1,
|
||||||
|
last_email_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
return new Response("Email processed successfully", { status: 200 });
|
return new Response("Email processed successfully", { status: 200 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Hono } from "hono";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Env, FeedConfig, FeedMetadata } from "../../types";
|
import { Env, FeedConfig, FeedMetadata } from "../../types";
|
||||||
import { generateFeedId } from "../../utils/id-generator";
|
import { generateFeedId } from "../../utils/id-generator";
|
||||||
|
import { bumpCounters } from "../../utils/stats";
|
||||||
import { waitUntilSafe } from "../../utils/worker";
|
import { waitUntilSafe } from "../../utils/worker";
|
||||||
import { feedRssUrl, feedEmailAddress } from "../../utils/urls";
|
import { feedRssUrl, feedEmailAddress } from "../../utils/urls";
|
||||||
import { logger } from "../../lib/logger";
|
import { logger } from "../../lib/logger";
|
||||||
@@ -191,6 +192,11 @@ feedsRouter.post("/create", async (c) => {
|
|||||||
expiresAt,
|
expiresAt,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await bumpCounters(emailStorage, {
|
||||||
|
feeds_created: 1,
|
||||||
|
last_feed_created_at: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
if (isJson) {
|
if (isJson) {
|
||||||
return c.json({
|
return c.json({
|
||||||
feedId,
|
feedId,
|
||||||
@@ -528,7 +534,10 @@ feedsRouter.post("/:feedId/delete", async (c) => {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteFeedFast(emailStorage, feedId);
|
await deleteFeedFast(emailStorage, feedId);
|
||||||
await removeFeedFromList(emailStorage, feedId);
|
const removed = await removeFeedFromList(emailStorage, feedId);
|
||||||
|
if (removed) {
|
||||||
|
await bumpCounters(emailStorage, { feeds_deleted: 1 });
|
||||||
|
}
|
||||||
|
|
||||||
waitUntilSafe(
|
waitUntilSafe(
|
||||||
c,
|
c,
|
||||||
@@ -658,6 +667,9 @@ feedsRouter.post("/bulk-delete", async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds);
|
const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds);
|
||||||
|
if (deletedFeedIds.length > 0) {
|
||||||
|
await bumpCounters(emailStorage, { feeds_deleted: deletedFeedIds.length });
|
||||||
|
}
|
||||||
|
|
||||||
const removed = new Set(deletedFeedIds);
|
const removed = new Set(deletedFeedIds);
|
||||||
okIds.forEach((feedId) => {
|
okIds.forEach((feedId) => {
|
||||||
@@ -707,6 +719,9 @@ feedsRouter.post("/bulk-delete", async (c) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds);
|
const deletedFeedIds = await removeFeedsFromListBulk(emailStorage, okIds);
|
||||||
|
if (deletedFeedIds.length > 0) {
|
||||||
|
await bumpCounters(emailStorage, { feeds_deleted: deletedFeedIds.length });
|
||||||
|
}
|
||||||
|
|
||||||
return c.redirect(
|
return c.redirect(
|
||||||
`${redirectBase}&message=bulkDeleted&count=${deletedFeedIds.length}`,
|
`${redirectBase}&message=bulkDeleted&count=${deletedFeedIds.length}`,
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ const designSystem = [variablesCss, layoutCss, componentsCss, utilitiesCss].join
|
|||||||
|
|
||||||
type LayoutProps = {
|
type LayoutProps = {
|
||||||
title: string;
|
title: string;
|
||||||
|
label?: string;
|
||||||
children: import("hono/jsx").Child;
|
children: import("hono/jsx").Child;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Layout = ({ title, children }: LayoutProps) => {
|
export const Layout = ({ title, label = "admin", children }: LayoutProps) => {
|
||||||
return (
|
return (
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<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">
|
<a href="https://kill-the.news/" class="site-header-logo" target="_blank" rel="noopener">
|
||||||
kill-the-news
|
kill-the-news
|
||||||
</a>
|
</a>
|
||||||
<span class="site-header-label">admin</span>
|
<span class="site-header-label">{label}</span>
|
||||||
</header>
|
</header>
|
||||||
{children}
|
{children}
|
||||||
<footer class="site-footer">
|
<footer class="site-footer">
|
||||||
|
|||||||
@@ -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 & 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>,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -1010,3 +1010,36 @@ table.table code {
|
|||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Status page — stat cards grid */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
|
gap: var(--spacing-md);
|
||||||
|
margin-bottom: var(--spacing-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--spacing-xs);
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-primary);
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value-time {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
}
|
||||||
|
|||||||
@@ -69,6 +69,23 @@ export interface FeedListItem {
|
|||||||
expires_at?: number; // Cached from FeedConfig to avoid per-feed KV reads
|
expires_at?: number; // Cached from FeedConfig to avoid per-feed KV reads
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cumulative monitoring counters (persisted as a KV singleton)
|
||||||
|
export interface Counters {
|
||||||
|
feeds_created: number;
|
||||||
|
feeds_deleted: number;
|
||||||
|
emails_received: number;
|
||||||
|
emails_rejected: number;
|
||||||
|
last_email_at?: string; // ISO 8601
|
||||||
|
last_feed_created_at?: string; // ISO 8601
|
||||||
|
first_seen?: string; // ISO 8601 — first time counters were written (instance start)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monitoring API response: persisted counters + live-computed values
|
||||||
|
export interface StatsResponse extends Counters {
|
||||||
|
active_feeds: number;
|
||||||
|
websub_subscriptions_active: number;
|
||||||
|
}
|
||||||
|
|
||||||
// WebSub (PubSubHubbub) subscription configuration
|
// WebSub (PubSubHubbub) subscription configuration
|
||||||
export interface WebSubSubscription {
|
export interface WebSubSubscription {
|
||||||
callbackUrl: string;
|
callbackUrl: string;
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { createMockEnv } from "../test/setup";
|
||||||
|
import {
|
||||||
|
getCounters,
|
||||||
|
bumpCounters,
|
||||||
|
countKeysByPrefix,
|
||||||
|
getStats,
|
||||||
|
} from "./stats";
|
||||||
|
import { STATS_KEY, FEEDS_LIST_KEY } from "../config/constants";
|
||||||
|
import { Env } from "../types";
|
||||||
|
|
||||||
|
describe("stats helper", () => {
|
||||||
|
it("returns zeroed counters when nothing is stored", async () => {
|
||||||
|
const env = createMockEnv() as unknown as Env;
|
||||||
|
const counters = await getCounters(env.EMAIL_STORAGE);
|
||||||
|
expect(counters).toMatchObject({
|
||||||
|
feeds_created: 0,
|
||||||
|
feeds_deleted: 0,
|
||||||
|
emails_received: 0,
|
||||||
|
emails_rejected: 0,
|
||||||
|
});
|
||||||
|
expect(counters.first_seen).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accumulates numeric deltas across bumps", async () => {
|
||||||
|
const env = createMockEnv() as unknown as Env;
|
||||||
|
const kv = env.EMAIL_STORAGE;
|
||||||
|
|
||||||
|
await bumpCounters(kv, { emails_received: 1 });
|
||||||
|
await bumpCounters(kv, { emails_received: 2, emails_rejected: 1 });
|
||||||
|
await bumpCounters(kv, { feeds_created: 1, feeds_deleted: 3 });
|
||||||
|
|
||||||
|
const counters = await getCounters(kv);
|
||||||
|
expect(counters.emails_received).toBe(3);
|
||||||
|
expect(counters.emails_rejected).toBe(1);
|
||||||
|
expect(counters.feeds_created).toBe(1);
|
||||||
|
expect(counters.feeds_deleted).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("overwrites date-time fields and sets first_seen once", async () => {
|
||||||
|
const env = createMockEnv() as unknown as Env;
|
||||||
|
const kv = env.EMAIL_STORAGE;
|
||||||
|
|
||||||
|
await bumpCounters(kv, {
|
||||||
|
emails_received: 1,
|
||||||
|
last_email_at: "2026-01-01T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
const first = await getCounters(kv);
|
||||||
|
const firstSeen = first.first_seen;
|
||||||
|
expect(firstSeen).toBeDefined();
|
||||||
|
expect(first.last_email_at).toBe("2026-01-01T00:00:00.000Z");
|
||||||
|
|
||||||
|
await bumpCounters(kv, {
|
||||||
|
emails_received: 1,
|
||||||
|
last_email_at: "2026-02-02T00:00:00.000Z",
|
||||||
|
});
|
||||||
|
const second = await getCounters(kv);
|
||||||
|
expect(second.last_email_at).toBe("2026-02-02T00:00:00.000Z");
|
||||||
|
expect(second.first_seen).toBe(firstSeen);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts keys by prefix", async () => {
|
||||||
|
const env = createMockEnv() as unknown as Env;
|
||||||
|
const kv = env.EMAIL_STORAGE;
|
||||||
|
await kv.put("websub:a:1", "{}");
|
||||||
|
await kv.put("websub:a:2", "{}");
|
||||||
|
await kv.put("feed:x:config", "{}");
|
||||||
|
|
||||||
|
expect(await countKeysByPrefix(kv, "websub:")).toBe(2);
|
||||||
|
expect(await countKeysByPrefix(kv, "missing:")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("getStats combines persisted counters with live values", async () => {
|
||||||
|
const env = createMockEnv() as unknown as Env;
|
||||||
|
const kv = env.EMAIL_STORAGE;
|
||||||
|
|
||||||
|
await kv.put(
|
||||||
|
FEEDS_LIST_KEY,
|
||||||
|
JSON.stringify({
|
||||||
|
feeds: [
|
||||||
|
{ id: "a", title: "A" },
|
||||||
|
{ id: "b", title: "B" },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await kv.put("websub:a:1", "{}");
|
||||||
|
await bumpCounters(kv, { emails_received: 5, feeds_created: 2 });
|
||||||
|
|
||||||
|
const stats = await getStats(env);
|
||||||
|
expect(stats.active_feeds).toBe(2);
|
||||||
|
expect(stats.websub_subscriptions_active).toBe(1);
|
||||||
|
expect(stats.emails_received).toBe(5);
|
||||||
|
expect(stats.feeds_created).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never throws on a failing KV (counters are best-effort)", async () => {
|
||||||
|
const brokenKv = {
|
||||||
|
get: async () => {
|
||||||
|
throw new Error("kv down");
|
||||||
|
},
|
||||||
|
put: async () => {
|
||||||
|
throw new Error("kv down");
|
||||||
|
},
|
||||||
|
} as unknown as KVNamespace;
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
bumpCounters(brokenKv, { emails_received: 1 }),
|
||||||
|
).resolves.toBeUndefined();
|
||||||
|
expect(await getCounters(brokenKv)).toMatchObject({ emails_received: 0 });
|
||||||
|
expect(await countKeysByPrefix(brokenKv, "websub:")).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("persists under the stats KV key", async () => {
|
||||||
|
const env = createMockEnv() as unknown as Env;
|
||||||
|
const kv = env.EMAIL_STORAGE;
|
||||||
|
await bumpCounters(kv, { feeds_created: 1 });
|
||||||
|
const raw = (await kv.get(STATS_KEY, { type: "json" })) as {
|
||||||
|
feeds_created: number;
|
||||||
|
};
|
||||||
|
expect(raw.feeds_created).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { Counters, Env, StatsResponse } from "../types";
|
||||||
|
import { STATS_KEY } from "../config/constants";
|
||||||
|
import { logger } from "../lib/logger";
|
||||||
|
import { listAllFeeds } from "../routes/admin/helpers";
|
||||||
|
|
||||||
|
const EMPTY_COUNTERS: Counters = {
|
||||||
|
feeds_created: 0,
|
||||||
|
feeds_deleted: 0,
|
||||||
|
emails_received: 0,
|
||||||
|
emails_rejected: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function getCounters(kv: KVNamespace): Promise<Counters> {
|
||||||
|
try {
|
||||||
|
const stored = (await kv.get(STATS_KEY, {
|
||||||
|
type: "json",
|
||||||
|
})) as Counters | null;
|
||||||
|
return { ...EMPTY_COUNTERS, ...(stored || {}) };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error reading counters", { error: String(error) });
|
||||||
|
return { ...EMPTY_COUNTERS };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-modify-write the counters singleton. KV has no atomic increment, so
|
||||||
|
* concurrent invocations can lose updates — accepted given KV's eventual
|
||||||
|
* consistency and this app's low volume (see email-processor.ts storeEmail).
|
||||||
|
* Never throws: counter failures must not break ingestion or admin flows.
|
||||||
|
*/
|
||||||
|
export async function bumpCounters(
|
||||||
|
kv: KVNamespace,
|
||||||
|
changes: Partial<Omit<Counters, "first_seen">>,
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const current = await getCounters(kv);
|
||||||
|
|
||||||
|
current.feeds_created += changes.feeds_created ?? 0;
|
||||||
|
current.feeds_deleted += changes.feeds_deleted ?? 0;
|
||||||
|
current.emails_received += changes.emails_received ?? 0;
|
||||||
|
current.emails_rejected += changes.emails_rejected ?? 0;
|
||||||
|
if (changes.last_email_at) current.last_email_at = changes.last_email_at;
|
||||||
|
if (changes.last_feed_created_at)
|
||||||
|
current.last_feed_created_at = changes.last_feed_created_at;
|
||||||
|
if (!current.first_seen) current.first_seen = new Date().toISOString();
|
||||||
|
|
||||||
|
await kv.put(STATS_KEY, JSON.stringify(current));
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error updating counters", { error: String(error) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function countKeysByPrefix(
|
||||||
|
kv: KVNamespace,
|
||||||
|
prefix: string,
|
||||||
|
): Promise<number> {
|
||||||
|
let total = 0;
|
||||||
|
let cursor: string | undefined;
|
||||||
|
try {
|
||||||
|
do {
|
||||||
|
const listed = await kv.list({ prefix, cursor, limit: 1000 });
|
||||||
|
total += listed.keys.length;
|
||||||
|
cursor = listed.list_complete ? undefined : listed.cursor;
|
||||||
|
} while (cursor);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error("Error counting keys", { prefix, error: String(error) });
|
||||||
|
}
|
||||||
|
return total;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getStats(env: Env): Promise<StatsResponse> {
|
||||||
|
const kv = env.EMAIL_STORAGE;
|
||||||
|
const [counters, feeds, websubCount] = await Promise.all([
|
||||||
|
getCounters(kv),
|
||||||
|
listAllFeeds(kv),
|
||||||
|
countKeysByPrefix(kv, "websub:"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...counters,
|
||||||
|
active_feeds: feeds.length,
|
||||||
|
websub_subscriptions_active: websubCount,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user