Commit8e9f1d9unknown_key
feat(polish): landing page live stats + autopilot observability
feat(polish): landing page live stats + autopilot observability - Landing page now accepts stats prop and shows public repo + user counts under the hero when available. Query in src/routes/web.tsx wrapped in try/catch; undefined stats gracefully hide the row. - Autopilot exposes getLastTick() + getTickCount() for observability. - New /admin/autopilot page (site-admin-only): status, interval, tick count, last tick timestamp, per-task status table, "run tick now" button. Audit-logged as admin.autopilot.run. Autopilot tests still 10/10.
4 files changed+221−48e9f1d9739e5805da0666cf8e60d726e9654b136
4 changed files+221−4
Modifiedsrc/lib/autopilot.ts+18−1View fileUnifiedSplit
@@ -187,6 +187,20 @@ export function startAutopilot(opts?: StartAutopilotOpts): { stop: () => void }
187187 };
188188}
189189
190/** Last tick snapshot for observability. Module-level, swap-on-complete. */
191let lastTick: AutopilotTickResult | null = null;
192let tickCount = 0;
193
194/** Return the most recent completed tick, or null if autopilot hasn't run yet. */
195export function getLastTick(): AutopilotTickResult | null {
196 return lastTick;
197}
198
199/** Return the total number of completed ticks in this process. */
200export function getTickCount(): number {
201 return tickCount;
202}
203
190204/**
191205 * Run one tick: invokes every sub-task with its own try/catch, records a
192206 * per-task result, and emits a single summary line. Never throws.
@@ -221,7 +235,10 @@ export async function runAutopilotTick(
221235 console.log(
222236 `[autopilot] tick ok tasks=${okCount}/${results.length} ms=${totalMs}`
223237 );
224 return { startedAt, finishedAt, tasks: results };
238 const result: AutopilotTickResult = { startedAt, finishedAt, tasks: results };
239 lastTick = result;
240 tickCount += 1;
241 return result;
225242}
226243
227244/** Exposed for unit tests. */
Modifiedsrc/routes/admin.tsx+147−0View fileUnifiedSplit
@@ -32,6 +32,11 @@ import {
3232} from "../lib/admin";
3333import { audit } from "../lib/notify";
3434import { sendDigestsToAll, sendDigestForUser } from "../lib/email-digest";
35import {
36 getLastTick,
37 getTickCount,
38 runAutopilotTick,
39} from "../lib/autopilot";
3540
3641const admin = new Hono<AuthEnv>();
3742admin.use("*", softAuth);
@@ -578,6 +583,148 @@ admin.post("/admin/digests/preview", async (c) => {
578583 );
579584});
580585
586admin.get("/admin/autopilot", async (c) => {
587 const g = await gate(c);
588 if (g instanceof Response) return g;
589 const { user } = g;
590 const tick = getLastTick();
591 const total = getTickCount();
592 const disabled = process.env.AUTOPILOT_DISABLED === "1";
593 const intervalRaw = process.env.AUTOPILOT_INTERVAL_MS;
594 const intervalMs =
595 intervalRaw && Number.isFinite(Number(intervalRaw)) && Number(intervalRaw) > 0
596 ? Number(intervalRaw)
597 : 5 * 60 * 1000;
598 const msg = c.req.query("result") || c.req.query("error");
599 const isErr = !!c.req.query("error");
600 return c.html(
601 <Layout title="Autopilot — admin" user={user}>
602 <div style="max-width: 960px; margin: 0 auto; padding: 24px 16px">
603 <h1 style="margin-bottom: 8px">Autopilot</h1>
604 <p style="color: var(--text-muted); margin-bottom: 24px">
605 Periodic platform-maintenance loop — mirror sync, merge-queue
606 progress, weekly digests, advisory rescans.
607 </p>
608 {msg && (
609 <div
610 class={isErr ? "auth-error" : "banner"}
611 style="margin-bottom: 16px"
612 >
613 {decodeURIComponent(msg)}
614 </div>
615 )}
616 <div
617 style="display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 12px; margin-bottom: 24px"
618 >
619 <div class="stat-card">
620 <div class="stat-label">Status</div>
621 <div class="stat-value">
622 {disabled ? "disabled" : "running"}
623 </div>
624 </div>
625 <div class="stat-card">
626 <div class="stat-label">Interval</div>
627 <div class="stat-value">{Math.round(intervalMs / 1000)}s</div>
628 </div>
629 <div class="stat-card">
630 <div class="stat-label">Ticks this process</div>
631 <div class="stat-value">{total}</div>
632 </div>
633 <div class="stat-card">
634 <div class="stat-label">Last tick</div>
635 <div class="stat-value" style="font-size: 14px">
636 {tick ? tick.finishedAt : "never"}
637 </div>
638 </div>
639 </div>
640 <form
641 method="post"
642 action="/admin/autopilot/run"
643 style="margin-bottom: 24px"
644 >
645 <button class="btn btn-primary" type="submit">
646 Run tick now
647 </button>
648 <span style="color: var(--text-muted); margin-left: 12px; font-size: 13px">
649 Executes all sub-tasks synchronously and records the result.
650 </span>
651 </form>
652 <h2 style="margin-bottom: 12px">Last tick tasks</h2>
653 {tick ? (
654 <table class="table" style="width: 100%">
655 <thead>
656 <tr>
657 <th style="text-align: left">Task</th>
658 <th style="text-align: left">Status</th>
659 <th style="text-align: right">Duration</th>
660 <th style="text-align: left">Error</th>
661 </tr>
662 </thead>
663 <tbody>
664 {tick.tasks.map((t) => (
665 <tr>
666 <td>
667 <code>{t.name}</code>
668 </td>
669 <td
670 style={
671 t.ok
672 ? "color: var(--green)"
673 : "color: var(--red)"
674 }
675 >
676 {t.ok ? "ok" : "failed"}
677 </td>
678 <td style="text-align: right">{t.durationMs}ms</td>
679 <td style="color: var(--text-muted); font-size: 13px">
680 {t.error || ""}
681 </td>
682 </tr>
683 ))}
684 </tbody>
685 </table>
686 ) : (
687 <p style="color: var(--text-muted)">
688 No ticks have run yet. The first tick fires after the interval
689 elapses. Click "Run tick now" to fire one immediately.
690 </p>
691 )}
692 <p style="margin-top: 32px; color: var(--text-muted); font-size: 13px">
693 Opt out with env <code>AUTOPILOT_DISABLED=1</code>. Adjust cadence
694 with <code>AUTOPILOT_INTERVAL_MS</code> (milliseconds).
695 </p>
696 </div>
697 </Layout>
698 );
699});
700
701admin.post("/admin/autopilot/run", async (c) => {
702 const g = await gate(c);
703 if (g instanceof Response) return g;
704 const { user } = g;
705 let summary = "";
706 try {
707 const result = await runAutopilotTick();
708 const ok = result.tasks.filter((t) => t.ok).length;
709 summary = `Tick complete: ${ok}/${result.tasks.length} tasks ok.`;
710 await audit({
711 userId: user.id,
712 action: "admin.autopilot.run",
713 targetType: "system",
714 targetId: "autopilot",
715 metadata: { ok, total: result.tasks.length },
716 });
717 return c.redirect(
718 `/admin/autopilot?result=${encodeURIComponent(summary)}`
719 );
720 } catch (err) {
721 const message = err instanceof Error ? err.message : String(err);
722 return c.redirect(
723 `/admin/autopilot?error=${encodeURIComponent("Tick failed: " + message)}`
724 );
725 }
726});
727
581728// Keep requireAuth import used even if some routes don't reference it here.
582729void requireAuth;
583730
Modifiedsrc/routes/web.tsx+19−2View fileUnifiedSplit
@@ -5,7 +5,7 @@
55
66import { Hono } from "hono";
77import { html } from "hono/html";
8import { eq, and, desc, inArray } from "drizzle-orm";
8import { eq, and, desc, inArray, sql } from "drizzle-orm";
99import { db } from "../db";
1010import {
1111 users,
@@ -62,9 +62,26 @@ web.get("/", async (c) => {
6262 return c.redirect("/dashboard");
6363 }
6464
65 let stats: { publicRepos?: number; users?: number } | undefined;
66 try {
67 const [repoRow] = await db
68 .select({ n: sql<number>`count(*)::int` })
69 .from(repositories)
70 .where(eq(repositories.isPrivate, false));
71 const [userRow] = await db
72 .select({ n: sql<number>`count(*)::int` })
73 .from(users);
74 stats = {
75 publicRepos: Number(repoRow?.n ?? 0),
76 users: Number(userRow?.n ?? 0),
77 };
78 } catch {
79 stats = undefined;
80 }
81
6582 return c.html(
6683 <Layout user={null}>
67 <LandingPage />
84 <LandingPage stats={stats} />
6885 </Layout>
6986 );
7087});
Modifiedsrc/views/landing.tsx+37−1View fileUnifiedSplit
@@ -17,7 +17,11 @@ export interface LandingPageProps {
1717 };
1818}
1919
20export const LandingPage: FC<LandingPageProps> = () => {
20export const LandingPage: FC<LandingPageProps> = ({ stats } = {}) => {
21 const hasStats =
22 stats &&
23 ((stats.publicRepos !== undefined && stats.publicRepos > 0) ||
24 (stats.users !== undefined && stats.users > 0));
2125 return (
2226 <>
2327 <style>{landingCss}</style>
@@ -43,6 +47,26 @@ export const LandingPage: FC<LandingPageProps> = () => {
4347 <p class="landing-trust">
4448 Self-hostable · AI built in · Open source mindset
4549 </p>
50 {hasStats && (
51 <p class="landing-stats">
52 {stats!.publicRepos !== undefined && stats!.publicRepos > 0 && (
53 <span>
54 <strong>{stats!.publicRepos.toLocaleString()}</strong> public
55 {stats!.publicRepos === 1 ? " repo" : " repos"}
56 </span>
57 )}
58 {stats!.publicRepos !== undefined &&
59 stats!.publicRepos > 0 &&
60 stats!.users !== undefined &&
61 stats!.users > 0 && <span class="landing-stats-sep"> · </span>}
62 {stats!.users !== undefined && stats!.users > 0 && (
63 <span>
64 <strong>{stats!.users.toLocaleString()}</strong>
65 {stats!.users === 1 ? " developer" : " developers"}
66 </span>
67 )}
68 </p>
69 )}
4670 </section>
4771
4872 {/* ---------- Features grid ---------- */}
@@ -258,6 +282,18 @@ const landingCss = `
258282 margin-top: 12px;
259283 letter-spacing: 0.02em;
260284 }
285 .landing-stats {
286 margin-top: 20px;
287 font-size: 14px;
288 color: var(--text-muted);
289 }
290 .landing-stats strong {
291 color: var(--fg);
292 font-weight: 600;
293 }
294 .landing-stats-sep {
295 opacity: 0.6;
296 }
261297
262298 /* ---------- Section scaffolding ---------- */
263299 .landing-section {
264300