mirror of
https://github.com/juherr/kill-the-news.git
synced 2026-06-20 22:03:48 +00:00
7bf0f71f86
Replace the history-driven lib/ + utils/ split with DDD layers: - domain/: aggregate, repositories, value objects, pure parsers/format - application/: feed-service, email-processor, feed-fetcher, stats - infrastructure/: logging, auth, KV/R2 adapters, HTTP, framework glue Pure file relocation; imports updated mechanically. Behaviour unchanged. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
234 lines
7.2 KiB
TypeScript
234 lines
7.2 KiB
TypeScript
import { Context } from "hono";
|
|
import { Env } from "../types";
|
|
import { getStats } from "../application/stats";
|
|
import { formatBytes } from "../domain/format";
|
|
import { R2_FREE_TIER_BYTES, KV_FREE_TIER_BYTES } from "../config/constants";
|
|
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");
|
|
}
|
|
|
|
function formatRelative(iso?: string): string {
|
|
if (!iso) return "Never";
|
|
const date = new Date(iso);
|
|
if (Number.isNaN(date.getTime())) return "Never";
|
|
const diffMs = Date.now() - date.getTime();
|
|
if (diffMs < 0) return "just now";
|
|
const seconds = Math.floor(diffMs / 1000);
|
|
if (seconds < 60) return "just now";
|
|
const minutes = Math.floor(seconds / 60);
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return `${hours}h ago`;
|
|
const days = Math.floor(hours / 24);
|
|
return `${days}d ago`;
|
|
}
|
|
|
|
function formatUptime(iso?: string): string {
|
|
if (!iso) return "—";
|
|
const date = new Date(iso);
|
|
if (Number.isNaN(date.getTime())) return "—";
|
|
const diffMs = Date.now() - date.getTime();
|
|
if (diffMs < 0) return "—";
|
|
const minutes = Math.floor(diffMs / 60000);
|
|
if (minutes < 60) return `${minutes} min`;
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return `${hours} ${hours === 1 ? "hour" : "hours"}`;
|
|
const days = Math.floor(hours / 24);
|
|
return `${days} ${days === 1 ? "day" : "days"}`;
|
|
}
|
|
|
|
function tierPercent(used: number, total: number): number {
|
|
if (total <= 0) return 0;
|
|
return Math.round((used / total) * 100);
|
|
}
|
|
|
|
function formatBytesOrDash(bytes?: number): string {
|
|
return bytes === undefined ? "—" : formatBytes(bytes);
|
|
}
|
|
|
|
type Tone = "success" | "danger";
|
|
|
|
type StatProps = {
|
|
label: string;
|
|
value: string | number;
|
|
tone?: Tone;
|
|
title?: string;
|
|
time?: boolean;
|
|
};
|
|
|
|
const Stat = ({ label, value, tone, title, time }: StatProps) => {
|
|
const valueClass = [
|
|
"stat-value",
|
|
time ? "stat-value-time" : "",
|
|
tone ? `stat-value-${tone}` : "",
|
|
]
|
|
.filter(Boolean)
|
|
.join(" ");
|
|
return (
|
|
<div class="card stat-card" title={title}>
|
|
<span class={valueClass}>{value}</span>
|
|
<span class="stat-label">{label}</span>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export async function handle(c: Context<{ Bindings: Env }>): Promise<Response> {
|
|
const stats = await getStats(c.env);
|
|
|
|
const netFeeds = stats.feeds_created - stats.feeds_deleted;
|
|
const totalEmails = stats.emails_received + stats.emails_rejected;
|
|
const acceptanceRate =
|
|
totalEmails > 0
|
|
? `${Math.round((stats.emails_received / totalEmails) * 100)}%`
|
|
: "—";
|
|
const avgEmailsPerFeed =
|
|
stats.feeds_created > 0
|
|
? (stats.emails_received / stats.feeds_created).toFixed(1)
|
|
: "—";
|
|
|
|
const kvPercent = tierPercent(
|
|
stats.kv_bytes_estimated ?? 0,
|
|
KV_FREE_TIER_BYTES,
|
|
);
|
|
const r2Percent = tierPercent(
|
|
stats.attachments_bytes ?? 0,
|
|
R2_FREE_TIER_BYTES,
|
|
);
|
|
|
|
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="card stat-hero">
|
|
<div class="stat-hero-main">
|
|
<span class="stat-hero-value">{stats.active_feeds}</span>
|
|
<span class="stat-hero-label">Active feeds</span>
|
|
</div>
|
|
<div class="stat-hero-aside">
|
|
<span class="stat-hero-aside-value">{stats.emails_received}</span>
|
|
<span class="stat-hero-aside-label">Emails received</span>
|
|
</div>
|
|
</div>
|
|
|
|
<section class="stat-section">
|
|
<h2 class="stat-section-title">Feeds</h2>
|
|
<div class="stats-grid">
|
|
<Stat label="Feeds created" value={stats.feeds_created} />
|
|
<Stat
|
|
label="Feeds deleted"
|
|
value={stats.feeds_deleted}
|
|
tone="danger"
|
|
/>
|
|
<Stat label="Net feeds" value={netFeeds} />
|
|
<Stat label="Unsubscribes sent" value={stats.unsubscribes_sent} />
|
|
<Stat
|
|
label="Last feed created"
|
|
value={formatRelative(stats.last_feed_created_at)}
|
|
title={formatDateTime(stats.last_feed_created_at)}
|
|
time
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="stat-section">
|
|
<h2 class="stat-section-title">Emails</h2>
|
|
<div class="stats-grid">
|
|
<Stat
|
|
label="Emails received"
|
|
value={stats.emails_received}
|
|
tone="success"
|
|
/>
|
|
<Stat
|
|
label="Emails rejected"
|
|
value={stats.emails_rejected}
|
|
tone="danger"
|
|
/>
|
|
<Stat
|
|
label="Acceptance rate"
|
|
value={acceptanceRate}
|
|
tone="success"
|
|
/>
|
|
<Stat label="Avg emails / feed" value={avgEmailsPerFeed} />
|
|
<Stat
|
|
label="Last email"
|
|
value={formatRelative(stats.last_email_at)}
|
|
title={formatDateTime(stats.last_email_at)}
|
|
time
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="stat-section">
|
|
<h2 class="stat-section-title">Distribution</h2>
|
|
<div class="stats-grid">
|
|
<Stat
|
|
label="WebSub subscribers"
|
|
value={stats.websub_subscriptions_active}
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
<section class="stat-section">
|
|
<h2 class="stat-section-title">Storage</h2>
|
|
<div class="stats-grid">
|
|
<Stat
|
|
label="KV space used (est.)"
|
|
value={formatBytesOrDash(stats.kv_bytes_estimated)}
|
|
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={formatBytesOrDash(stats.attachments_bytes)}
|
|
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">
|
|
<Stat label="Uptime" value={formatUptime(stats.first_seen)} time />
|
|
<Stat
|
|
label="Online since"
|
|
value={formatRelative(stats.first_seen)}
|
|
title={formatDateTime(stats.first_seen)}
|
|
time
|
|
/>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</Layout>,
|
|
);
|
|
}
|