mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
feat(attachments): R2 toggle, storage metrics, and demo R2 config
Add an ATTACHMENTS_ENABLED switch (default on when R2 is bound) via a central getAttachmentBucket helper, surface R2 + estimated KV usage against the free tier on the status page and /api/stats (refreshed by the hourly cron), let setup.sh create and wire the R2 bucket, and bind the demo bucket so the deployed demo has attachments. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -10,6 +10,7 @@ import { logger } from "../../lib/logger";
|
||||
import { Layout, clampText } from "./ui";
|
||||
import { deleteKeysWithConcurrency } from "./helpers";
|
||||
import { feedRssUrl, feedAtomUrl, feedEmailAddress } from "../../utils/urls";
|
||||
import { getAttachmentBucket } from "../../utils/attachments";
|
||||
import { emailsPageScript } from "../../scripts/generated/emails-page";
|
||||
|
||||
type AppEnv = { Bindings: Env };
|
||||
@@ -643,9 +644,10 @@ emailsRouter.post("/emails/:emailKey/delete", async (c) => {
|
||||
await emailStorage.put(feedMetadataKey, JSON.stringify(feedMetadata));
|
||||
}
|
||||
|
||||
if (env.ATTACHMENT_BUCKET && attachmentIds.length > 0) {
|
||||
const attachmentBucket = getAttachmentBucket(env);
|
||||
if (attachmentBucket && attachmentIds.length > 0) {
|
||||
await Promise.allSettled(
|
||||
attachmentIds.map((id) => env.ATTACHMENT_BUCKET!.delete(id)),
|
||||
attachmentIds.map((id) => attachmentBucket.delete(id)),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -726,9 +728,10 @@ emailsRouter.post("/feeds/:feedId/emails/bulk-delete", async (c) => {
|
||||
);
|
||||
await emailStorage.put(feedMetadataKey, JSON.stringify(feedMetadata));
|
||||
|
||||
if (env.ATTACHMENT_BUCKET && r2AttachmentIds.length > 0) {
|
||||
const attachmentBucket = getAttachmentBucket(env);
|
||||
if (attachmentBucket && r2AttachmentIds.length > 0) {
|
||||
await Promise.allSettled(
|
||||
r2AttachmentIds.map((id) => env.ATTACHMENT_BUCKET!.delete(id)),
|
||||
r2AttachmentIds.map((id) => attachmentBucket.delete(id)),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import { waitUntilSafe } from "../../utils/worker";
|
||||
import { feedRssUrl, feedEmailAddress } from "../../utils/urls";
|
||||
import { logger } from "../../lib/logger";
|
||||
import { sendUnsubscribes } from "../../utils/unsubscribe";
|
||||
import { getAttachmentBucket } from "../../utils/attachments";
|
||||
import { Layout } from "./ui";
|
||||
import {
|
||||
addFeedToList,
|
||||
@@ -555,7 +556,7 @@ feedsRouter.post("/:feedId/delete", async (c) => {
|
||||
waitUntilSafe(
|
||||
c,
|
||||
purgeFeedKeysStep(emailStorage, feedId, {
|
||||
bucket: env.ATTACHMENT_BUCKET,
|
||||
bucket: getAttachmentBucket(env),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -594,7 +595,7 @@ feedsRouter.post("/:feedId/purge", async (c) => {
|
||||
const step = await purgeFeedKeysStep(emailStorage, feedId, {
|
||||
cursor,
|
||||
limit,
|
||||
bucket: env.ATTACHMENT_BUCKET,
|
||||
bucket: getAttachmentBucket(env),
|
||||
});
|
||||
|
||||
return c.json({
|
||||
|
||||
@@ -2,12 +2,7 @@ import { Context } from "hono";
|
||||
import { html, raw } from "hono/html";
|
||||
import { Env, FeedConfig, FeedMetadata, EmailData } from "../types";
|
||||
import { processEmailContent } from "../utils/html-processor";
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
}
|
||||
import { formatBytes } from "../utils/format";
|
||||
|
||||
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
||||
const feedId = c.req.param("feedId");
|
||||
|
||||
+4
-2
@@ -1,8 +1,10 @@
|
||||
import { Context } from "hono";
|
||||
import { Env } from "../types";
|
||||
import { getAttachmentBucket } from "../utils/attachments";
|
||||
|
||||
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
||||
if (!c.env.ATTACHMENT_BUCKET) {
|
||||
const bucket = getAttachmentBucket(c.env);
|
||||
if (!bucket) {
|
||||
return new Response("Attachment storage not configured", { status: 404 });
|
||||
}
|
||||
|
||||
@@ -13,7 +15,7 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
||||
return new Response("Not found", { status: 404 });
|
||||
}
|
||||
|
||||
const object = await c.env.ATTACHMENT_BUCKET.get(attachmentId);
|
||||
const object = await bucket.get(attachmentId);
|
||||
|
||||
if (!object) {
|
||||
return new Response("Not found", { status: 404 });
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { Context } from "hono";
|
||||
import { Env } from "../types";
|
||||
import { getStats } from "../utils/stats";
|
||||
import { formatBytes } from "../utils/format";
|
||||
import { R2_FREE_TIER_BYTES, KV_FREE_TIER_BYTES } from "../config/constants";
|
||||
import { Layout } from "./admin/ui";
|
||||
|
||||
function formatDateTime(iso?: string): string {
|
||||
@@ -43,6 +45,11 @@ function formatUptime(iso?: string): string {
|
||||
return `${days} ${days === 1 ? "day" : "days"}`;
|
||||
}
|
||||
|
||||
function tierPercent(used: number, total: number): number {
|
||||
if (total <= 0) return 0;
|
||||
return Math.round((used / total) * 100);
|
||||
}
|
||||
|
||||
type Tone = "success" | "danger";
|
||||
|
||||
type StatProps = {
|
||||
@@ -83,6 +90,11 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
||||
? (stats.emails_received / stats.feeds_created).toFixed(1)
|
||||
: "—";
|
||||
|
||||
const kvBytes = stats.kv_bytes_estimated;
|
||||
const kvPercent = tierPercent(kvBytes ?? 0, KV_FREE_TIER_BYTES);
|
||||
const r2Bytes = stats.attachments_bytes;
|
||||
const r2Percent = tierPercent(r2Bytes ?? 0, R2_FREE_TIER_BYTES);
|
||||
|
||||
return c.html(
|
||||
<Layout title="Status" label="status">
|
||||
<div class="container fade-in">
|
||||
@@ -167,6 +179,34 @@ export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stat-section">
|
||||
<h2 class="stat-section-title">Storage</h2>
|
||||
<div class="stats-grid">
|
||||
<Stat
|
||||
label="KV space used (est.)"
|
||||
value={kvBytes === undefined ? "—" : formatBytes(kvBytes)}
|
||||
title={`${kvPercent}% of 1 GB free tier — estimate`}
|
||||
tone={kvPercent >= 80 ? "danger" : undefined}
|
||||
/>
|
||||
{stats.attachments_enabled ? (
|
||||
<>
|
||||
<Stat
|
||||
label="Attachments stored"
|
||||
value={stats.attachments_count ?? "—"}
|
||||
/>
|
||||
<Stat
|
||||
label="R2 space used"
|
||||
value={r2Bytes === undefined ? "—" : formatBytes(r2Bytes)}
|
||||
title={`${r2Percent}% of 10 GB free tier`}
|
||||
tone={r2Percent >= 80 ? "danger" : undefined}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<Stat label="Attachments (R2)" value="Off" />
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="stat-section">
|
||||
<h2 class="stat-section-title">Instance</h2>
|
||||
<div class="stats-grid">
|
||||
|
||||
Reference in New Issue
Block a user