diff --git a/tools/star-tracker/.dev.vars.example b/tools/star-tracker/.dev.vars.example index eb60ace..aca932c 100644 --- a/tools/star-tracker/.dev.vars.example +++ b/tools/star-tracker/.dev.vars.example @@ -8,3 +8,10 @@ JWT_SECRET=replace-with-32-plus-bytes-of-randomness # Overrides the wrangler.toml [vars] value during local dev so the OAuth # redirect_uri matches your dev GitHub OAuth App's callback URL. PUBLIC_URL=http://localhost:8787 + +# Comma-separated GitHub logins allowed to access /_admin (operator view of +# every registered tenant). Leave unset to disable the page entirely. +# Examples: +# ADMIN_USERNAMES=octocat +# ADMIN_USERNAMES=octocat,hubot,monalisa +ADMIN_USERNAMES=your-github-login diff --git a/tools/star-tracker/src/db.ts b/tools/star-tracker/src/db.ts index e92e16e..b625763 100644 --- a/tools/star-tracker/src/db.ts +++ b/tools/star-tracker/src/db.ts @@ -530,6 +530,36 @@ export async function listAllTenants(db: D1Database): Promise { return results ?? []; } +export type TenantWithStats = Tenant & { + owner_username: string | null; + owner_avatar: string | null; + public_repos: number; + private_repos: number; + total_stars: number; +}; + +// Cross-tenant rollup for the operator admin view. One SQL pass joins the +// owner, counts public/private repos, and sums public stargazers so the +// page can render without per-tenant fan-out queries. +export async function listAllTenantsWithStats(db: D1Database): Promise { + const { results } = await db + .prepare( + `SELECT t.slug, t.owner_user_id, t.webhook_secret, t.display_name, + t.created_at, t.last_ping_at, t.last_event_at, + u.username AS owner_username, u.avatar_url AS owner_avatar, + COALESCE(SUM(CASE WHEN r.private = 0 THEN 1 ELSE 0 END), 0) AS public_repos, + COALESCE(SUM(CASE WHEN r.private = 1 THEN 1 ELSE 0 END), 0) AS private_repos, + COALESCE(SUM(CASE WHEN r.private = 0 THEN r.stargazers_count ELSE 0 END), 0) AS total_stars + FROM tenants t + LEFT JOIN users u ON u.id = t.owner_user_id + LEFT JOIN repos r ON r.tenant_slug = t.slug + GROUP BY t.slug + ORDER BY t.created_at DESC`, + ) + .all(); + return results ?? []; +} + // -- Lite analytics --------------------------------------------------------- export type ViewKind = 'chart' | 'page'; diff --git a/tools/star-tracker/src/index.ts b/tools/star-tracker/src/index.ts index 4701f9e..e5b79f3 100644 --- a/tools/star-tracker/src/index.ts +++ b/tools/star-tracker/src/index.ts @@ -32,6 +32,7 @@ export interface Env { GITHUB_CLIENT_SECRET: string; JWT_SECRET: string; PUBLIC_URL: string; + ADMIN_USERNAMES?: string; }; Variables: { user: db.User | null }; } @@ -270,6 +271,27 @@ app.get('/dashboard', async (c) => { return c.html(pages.dashboard(user, tenants, c.env.PUBLIC_URL)); }); +// -- Admin ------------------------------------------------------------------ + +// Parses ADMIN_USERNAMES (comma-separated GitHub logins) and returns true +// if the signed-in user is on the list. Case-insensitive match. +function isAdmin(user: db.User | null, raw: string | undefined): boolean { + if (!user || !raw) return false; + const allow = raw.split(',').map((s) => s.trim().toLowerCase()).filter(Boolean); + return allow.includes(user.username.toLowerCase()); +} + +// Operator-only cross-tenant view: every registered tenant, newest first, +// with repo counts + total stars. Path uses an underscore prefix so it +// can't collide with a tenant slug (SLUG_RE requires [a-z0-9] first char). +// Non-admins get 404 rather than 403 — no need to advertise the page. +app.get('/_admin', async (c) => { + const user = c.get('user'); + if (!isAdmin(user, c.env.ADMIN_USERNAMES)) return c.notFound(); + const tenants = await db.listAllTenantsWithStats(c.env.DB); + return c.html(pages.adminTenants(user!, tenants)); +}); + // -- Tenant creation -------------------------------------------------------- app.post('/tenants', async (c) => { diff --git a/tools/star-tracker/src/pages.ts b/tools/star-tracker/src/pages.ts index f3be1a9..f736307 100644 --- a/tools/star-tracker/src/pages.ts +++ b/tools/star-tracker/src/pages.ts @@ -1,7 +1,7 @@ // Server-rendered HTML pages. Inline CSS, no JS — keeps the bundle small and // the UX dependable inside a Worker. -import type { EventCounts, RepoRecent, RepoRow, Tenant, User, ViewsSummary } from './db'; +import type { EventCounts, RepoRecent, RepoRow, Tenant, TenantWithStats, User, ViewsSummary } from './db'; // Human-friendly relative timestamp ("3 minutes ago"). Used for webhook // status — absolute UTC strings are precise but require mental math; "5 @@ -807,6 +807,63 @@ ${views ? viewsPanel(views) : ''} ); } +// Operator admin: every registered tenant, newest first. Reached only by +// users listed in ADMIN_USERNAMES; non-admins 404 before this renders. +export function adminTenants(user: User, tenants: TenantWithStats[]): string { + const sevenDays = 7 * 86400_000; + const now = Date.now(); + const active7d = tenants.filter((t) => t.last_event_at && now - Date.parse(t.last_event_at) <= sevenDays).length; + const pinged = tenants.filter((t) => t.last_ping_at).length; + const totalRepos = tenants.reduce((s, t) => s + t.public_repos + t.private_repos, 0); + const totalStars = tenants.reduce((s, t) => s + t.total_stars, 0); + + const summary = ` +
+
+
${tenants.length} tenants
+
${pinged} with ping
+
${active7d} active 7d
+
${totalRepos.toLocaleString('en-US')} repos
+
${totalStars.toLocaleString('en-US')} stars tracked
+
+
`; + + const rows = tenants.length === 0 + ? `

No tenants registered yet.

` + : `
    ${tenants.map((t) => { + const owner = t.owner_username + ? `@${esc(t.owner_username)}` + : `unknown owner`; + const repoBits: string[] = []; + if (t.public_repos) repoBits.push(`${t.public_repos} public`); + if (t.private_repos) repoBits.push(`${t.private_repos} private`); + const repoLine = repoBits.length ? repoBits.join(' · ') : 'no repos'; + const stars = t.total_stars > 0 ? ` · ${t.total_stars.toLocaleString('en-US')}★` : ''; + const created = new Date(t.created_at).toISOString().slice(0, 10); + return `
  • +
    + + ${esc(t.slug)} + — ${owner} + + registered ${created} +
    +
    + ${esc(repoLine)}${stars} · last event ${esc(relTime(t.last_event_at))} · last ping ${esc(relTime(t.last_ping_at))} +
    +
  • `; + }).join('')}
`; + + return shell( + 'Admin · tenants', + user, + `

All tenants

+

Operator view — every org/user that's registered a tracker, newest first.

+${summary} +${rows}`, + ); +} + export function error(user: User | null, status: number, message: string): string { return shell(`Error ${status}`, user, `

${status}

${esc(message)}

`); } diff --git a/tools/star-tracker/wrangler.toml b/tools/star-tracker/wrangler.toml index ca45c18..90ace52 100644 --- a/tools/star-tracker/wrangler.toml +++ b/tools/star-tracker/wrangler.toml @@ -25,5 +25,7 @@ custom_domain = true # GITHUB_CLIENT_ID — OAuth App client ID # GITHUB_CLIENT_SECRET — OAuth App client secret # JWT_SECRET — random 32+ byte string for signing session cookies +# ADMIN_USERNAMES — comma-separated GitHub logins allowed to view /_admin (optional) +# e.g. "octocat" or "octocat,hubot,monalisa" [vars] PUBLIC_URL = "https://stars.wavekat.com"