CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history
traffic.tsx
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 8f50ed0 | 1 | /** |
| 2 | * Block F1 — Traffic analytics UI. | |
| 3 | * | |
| 4 | * GET /:owner/:repo/traffic — owner-only 14-day views/clones chart, | |
| 5 | * unique visitors, top paths + referers. | |
| 6 | */ | |
| 7 | ||
| 8 | import { Hono } from "hono"; | |
| 9 | import { and, eq } from "drizzle-orm"; | |
| 10 | import { db } from "../db"; | |
| 11 | import { repositories, users } from "../db/schema"; | |
| 12 | import { Layout } from "../views/layout"; | |
| 13 | import { RepoHeader, RepoNav } from "../views/components"; | |
| 14 | import { softAuth, requireAuth } from "../middleware/auth"; | |
| 15 | import type { AuthEnv } from "../middleware/auth"; | |
| 16 | import { summarise } from "../lib/traffic"; | |
| 17 | ||
| 18 | const traffic = new Hono<AuthEnv>(); | |
| 19 | traffic.use("*", softAuth); | |
| 20 | ||
| 21 | async function loadRepo(owner: string, repo: string) { | |
| 22 | try { | |
| 23 | const [row] = await db | |
| 24 | .select({ | |
| 25 | id: repositories.id, | |
| 26 | name: repositories.name, | |
| 27 | ownerId: repositories.ownerId, | |
| 28 | starCount: repositories.starCount, | |
| 29 | forkCount: repositories.forkCount, | |
| 30 | }) | |
| 31 | .from(repositories) | |
| 32 | .innerJoin(users, eq(repositories.ownerId, users.id)) | |
| 33 | .where(and(eq(users.username, owner), eq(repositories.name, repo))) | |
| 34 | .limit(1); | |
| 35 | return row || null; | |
| 36 | } catch { | |
| 37 | return null; | |
| 38 | } | |
| 39 | } | |
| 40 | ||
| 41 | traffic.get("/:owner/:repo/traffic", requireAuth, async (c) => { | |
| 42 | const user = c.get("user")!; | |
| 43 | const { owner, repo } = c.req.param(); | |
| 44 | const repoRow = await loadRepo(owner, repo); | |
| 45 | if (!repoRow) return c.notFound(); | |
| 46 | if (repoRow.ownerId !== user.id) { | |
| 47 | return c.redirect(`/${owner}/${repo}`); | |
| 48 | } | |
| 49 | ||
| 50 | const windowDays = Math.max( | |
| 51 | 1, | |
| 52 | Math.min(90, parseInt(c.req.query("days") || "14", 10) || 14) | |
| 53 | ); | |
| 54 | const summary = await summarise(repoRow.id, windowDays); | |
| 55 | ||
| 56 | // Simple ascii-bar chart scaled to the max day. | |
| 57 | const maxN = Math.max( | |
| 58 | 1, | |
| 59 | ...summary.daily.map((d) => d.views + d.clones) | |
| 60 | ); | |
| 61 | ||
| 62 | return c.html( | |
| 63 | <Layout title={`Traffic — ${owner}/${repo}`} user={user}> | |
| 64 | <RepoHeader | |
| 65 | owner={owner} | |
| 66 | repo={repo} | |
| 67 | starCount={repoRow.starCount} | |
| 68 | forkCount={repoRow.forkCount} | |
| 69 | currentUser={user.username} | |
| 70 | /> | |
| 71 | <RepoNav owner={owner} repo={repo} active="insights" /> | |
| 72 | ||
| 73 | <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px"> | |
| 74 | <h3>Traffic ({windowDays}d)</h3> | |
| 75 | <div> | |
| 76 | {[7, 14, 30, 90].map((d) => ( | |
| 77 | <a | |
| 78 | href={`/${owner}/${repo}/traffic?days=${d}`} | |
| 79 | class={`btn btn-sm ${d === windowDays ? "btn-primary" : ""}`} | |
| 80 | style="margin-left:4px" | |
| 81 | > | |
| 82 | {d}d | |
| 83 | </a> | |
| 84 | ))} | |
| 85 | </div> | |
| 86 | </div> | |
| 87 | ||
| 88 | <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;margin-bottom:20px"> | |
| 89 | <div class="panel" style="padding:12px;text-align:center"> | |
| 90 | <div style="font-size:22px;font-weight:700;color:#79c0ff"> | |
| 91 | {summary.totalViews} | |
| 92 | </div> | |
| 93 | <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase"> | |
| 94 | Views | |
| 95 | </div> | |
| 96 | </div> | |
| 97 | <div class="panel" style="padding:12px;text-align:center"> | |
| 98 | <div style="font-size:22px;font-weight:700;color:#d2a8ff"> | |
| 99 | {summary.totalClones} | |
| 100 | </div> | |
| 101 | <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase"> | |
| 102 | Clones | |
| 103 | </div> | |
| 104 | </div> | |
| 105 | <div class="panel" style="padding:12px;text-align:center"> | |
| 106 | <div style="font-size:22px;font-weight:700;color:var(--green)"> | |
| 107 | {summary.uniqueVisitorsApprox} | |
| 108 | </div> | |
| 109 | <div style="font-size:11px;color:var(--text-muted);text-transform:uppercase"> | |
| 110 | Unique (approx) | |
| 111 | </div> | |
| 112 | </div> | |
| 113 | </div> | |
| 114 | ||
| 115 | <h4>Daily</h4> | |
| 116 | <div class="panel" style="margin-bottom:20px;padding:12px"> | |
| 117 | {summary.daily.length === 0 ? ( | |
| 118 | <p style="color:var(--text-muted);font-size:13px"> | |
| 119 | No traffic recorded yet. Views are tracked automatically as people | |
| 120 | visit this repo; clones + API hits are tracked on git-http access. | |
| 121 | </p> | |
| 122 | ) : ( | |
| 123 | summary.daily.map((d) => { | |
| 124 | const total = d.views + d.clones; | |
| 125 | const pct = Math.round((total / maxN) * 100); | |
| 126 | return ( | |
| 127 | <div style="display:flex;align-items:center;gap:8px;font-size:12px;padding:3px 0"> | |
| 128 | <span | |
| 129 | style="font-family:var(--font-mono);color:var(--text-muted);width:88px" | |
| 130 | > | |
| 131 | {d.day} | |
| 132 | </span> | |
| 133 | <div | |
| 134 | style={`flex:1;height:14px;background:var(--bg-tertiary);border-radius:3px;position:relative;overflow:hidden`} | |
| 135 | > | |
| 136 | <div | |
| 137 | style={`position:absolute;left:0;top:0;bottom:0;width:${pct}%;background:linear-gradient(90deg,#79c0ff ${d.views / Math.max(1, total) * 100}%,#d2a8ff ${d.views / Math.max(1, total) * 100}%)`} | |
| 138 | /> | |
| 139 | </div> | |
| 140 | <span style="font-family:var(--font-mono);width:56px;text-align:right"> | |
| 141 | {d.views}v / {d.clones}c | |
| 142 | </span> | |
| 143 | </div> | |
| 144 | ); | |
| 145 | }) | |
| 146 | )} | |
| 147 | </div> | |
| 148 | ||
| 149 | <div style="display:grid;grid-template-columns:1fr 1fr;gap:20px"> | |
| 150 | <div> | |
| 151 | <h4>Top paths</h4> | |
| 152 | <div class="panel"> | |
| 153 | {summary.topPaths.length === 0 ? ( | |
| 154 | <div class="panel-empty">No paths recorded.</div> | |
| 155 | ) : ( | |
| 156 | summary.topPaths.map((p) => ( | |
| 157 | <div class="panel-item" style="justify-content:space-between"> | |
| 158 | <code style="font-size:12px;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block"> | |
| 159 | {p.path} | |
| 160 | </code> | |
| 161 | <span style="font-family:var(--font-mono);color:var(--text-muted)"> | |
| 162 | {p.n} | |
| 163 | </span> | |
| 164 | </div> | |
| 165 | )) | |
| 166 | )} | |
| 167 | </div> | |
| 168 | </div> | |
| 169 | <div> | |
| 170 | <h4>Top referers</h4> | |
| 171 | <div class="panel"> | |
| 172 | {summary.topReferers.length === 0 ? ( | |
| 173 | <div class="panel-empty">No external referers.</div> | |
| 174 | ) : ( | |
| 175 | summary.topReferers.map((r) => ( | |
| 176 | <div class="panel-item" style="justify-content:space-between"> | |
| 177 | <span | |
| 178 | style="font-size:12px;max-width:220px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block" | |
| 179 | > | |
| 180 | {r.referer} | |
| 181 | </span> | |
| 182 | <span style="font-family:var(--font-mono);color:var(--text-muted)"> | |
| 183 | {r.n} | |
| 184 | </span> | |
| 185 | </div> | |
| 186 | )) | |
| 187 | )} | |
| 188 | </div> | |
| 189 | </div> | |
| 190 | </div> | |
| 191 | </Layout> | |
| 192 | ); | |
| 193 | }); | |
| 194 | ||
| 195 | export default traffic; |