Files
ghglance/docs/system-architecture.md
T
tiennm99 ad775adffa feat(web): add web UI and Coolify compose deployment
`ghglance -serve :8080` runs a web UI from the same binary: submit a
username, a background job renders every card in every theme, and
/u/<user> shows them again with a theme picker and embed URLs.

- Cards are stored on disk with atomic symlink publishing and deleted
  after -retention (default 24h); -cooldown limits token-less regeneration.
- Token-less jobs use a public-only server token with private and org
  scope forced off; a submitter's own token is used for that job only and
  never stored or logged. The form links to GitHub's new-token page with
  the needed scopes pre-ticked.
- The CLI and web share one fetch helper; CLI output is unchanged.
- compose.yml deploys to Coolify with a data volume and /healthz check.
2026-10-07 11:08:07 +07:00

11 KiB
Raw Blame History

System Architecture

Runtime shape

One process, three phases: flag parsing → data fetch → SVG render.

┌───────────┐   ┌──────────────────┐   ┌─────────────────┐   ┌──────────────┐
│ flag / env│──►│  internal/github │──►│  internal/card  │──►│  output/*.svg│
│  parsing  │   │  (GraphQL only)  │   │  (pure render)  │   │   per theme  │
└───────────┘   └──────────────────┘   └─────────────────┘   └──────────────┘
                         ▲                       ▲
                         │                       │
                    api.github.com        internal/theme

No database, no cache, no background workers in CLI mode. Stateless CLI; Action runtime just sets environment variables + runs the binary. -serve switches the same binary into the long-running web UI described under Web UI mode.

A root context.Context is built in main.go with an overall deadline (-timeout, default 30m) and cancelled on SIGINT/SIGTERM. Every fetcher and HTTP request inherits it so a slow run aborts cleanly instead of draining the 6h Action budget.

Data-fetch sequence

github.Collect owns this sequence; the CLI and every web UI job call it, so the two paths cannot drift. Only the profile fetch is fatal; later stages report through CollectConfig.Warnf and leave partial data.

github.Collect(ctx, client, login, cfg)
  │
  ▼
FetchProfile(ctx, login, opts)
  │  profileQuery × N pages (owned repos, STARGAZERS desc, 100/page)
  │  ownerAffiliations = [OWNER] (+ ORGANIZATION_MEMBER when
  │    opts.IncludeOrgRepos; non-ADMIN org repos dropped client-side)
  │  yields: Profile.{identity, stars, forks, PRs, issues,
  │                   TopRepos, ReposByLanguage,
  │                   ContributionYears,
  │                   DailyContributions (last year),
  │                   TotalCommits (last year)}
  │
  ▼
FetchContributionsAllTime(ctx, profile, opts)
  │  contributionYearQuery × 4 quarters × len(ContributionYears)
  │  per quarter: totalCommitContributions +
  │            contributionCalendar.weeks +
  │            commitContributionsByRepository(maxRepositories: 100)
  │  quarters keep most windows under the 100-repo ceiling, which a
  │    year-wide window silently truncates at; a quarter that still
  │    saturates is re-queried per month for repos only (its days and
  │    totals are already folded in)
  │  yields: SeedRepos (deduped),
  │          DailyContributionsAllTime,
  │          TotalCommitsAllTime
  │
  ▼
FetchProductive(ctx, profile, SeedRepos[:cfg.TopRepos], loc, commitsPerRepo)  // 0 = no cap
  │  commitHistoryQuery × (#seeds × pages)
  │  per commit: t = committedDate in loc
  │              ProductiveAllTime[t.Hour]++
  │              WeekdayAllTime[t.Weekday]++  + language votes
  │              if t.After(yearAgo): Productive[t.Hour]++
  │                                   Weekday[t.Weekday]++ + language votes
  │  yields: Productive, Weekday, ProductiveAllTime, WeekdayAllTime,
  │          CommitsByLanguage, CommitsByLanguageAllTime
  │
  ▼
card.RenderAll(profile, theme, outDir)  ×  len(themes)   // caller's step

GraphQL queries

All three queries live in internal/github/queries.go.

Query Purpose Cost estimate
profileQuery Profile identity + totals + owned repos + last-year calendar 1–10 calls (100 repos/page × ≤10 pages safety cap)
contributionYearQuery Per-quarter calendar + seed list 4 calls per active year, +3 per saturated quarter
commitHistoryQuery Authored commits on default branch 1 call per 100 commits per seed repo

Typical run (8 active years, 30 seed repos, avg 50 commits each):

  • profile: 1 call
  • quarter loop: 8 × 4 = 32 calls
  • commit history: 30 × 1 = 30 calls
  • ≈ 63 GraphQL calls, 0 REST calls

Attribution model

Language attribution for the "most commit language" card is byte-weighted:

for each repo R:
    total_bytes = Σ R.languages[*].bytes   // precomputed once per repo
    for each commit C in R:
        for each (lang, bytes) in R.languages:
            commits_by_lang[lang] += scaleFactor × bytes / total_bytes

Implementation in internal/github/productive.go:attributeCommit. The per-repo byte total is hoisted out of the commit loop so the hot path doesn't re-sum language edges for every commit. scaleFactor = 10_000 preserves fractional precision in int64 storage — percentages rendered in the card are unaffected by magnitude.

Known distortion: linguist excludes prose types (Markdown, AsciiDoc, reST) from byte counts. Blog-style repos with 95% Markdown and 5% JS still attribute all commits to JS. Future fix: per-commit REST file classification via -accurate-languages (see roadmap).

SVG generation

Each card produces a self-contained SVG with:

  • Card frame (rounded rect, theme background, theme stroke + opacity)
  • Title (top-left, theme title color)
  • Content layer (chart elements, text, legend)

Shared primitives:

  • renderDonutCard(title, stats, theme) — pie slices via polar arc math + legend with color swatches (top 7 entries, rest collapse into "Other"). Single-slice case (one language at 100%) renders as two concentric <circle> elements instead of an arc, since SVG's A command from point P back to P draws nothing.
  • renderProductiveTime(title, hours, theme) — 24 bars + both axes + tick math from niceTicks
  • renderWeekday(title, data, theme) — 7-bar day-of-week chart mirroring the productive-time layout; peak bar uses theme.Accent, others mixHex(Background, Accent, 0.55)
  • renderHeatmap(title, days, theme) — 7×53 calendar grid with a 5-bucket intensity ramp synthesised from theme.Background → theme.Accent
  • renderContributions(title, days, theme) — monthly aggregation, Catmull-Rom → cubic Bezier area path, two-sided Y axis

Chart-geometry invariants:

  • niceTicks(max, 5) rounds the top tick up to the next step (last = ceil(max/step) × step), guaranteeing yMax ≥ dataMax — bar heights can never exceed chartH and collide with the title row.
  • formatTick abbreviates ≥ 1000 to k / M / B so y-axis labels never exceed 4 characters (10000 → "10k", 1234567 → "1.2M"); keeps the left gutter ≤ 28 px for every profile.
  • header() picks the largest title font in [11, 15] px at which the string fits in width − 24 at a 0.6 char-width estimate, so long titles like Commits by Weekday (last year, UTC+7.00) still fit the frame.

Catmull-Rom control-point math: for each segment P_i → P_{i+1},

C1 = P_i + (P_{i+1} - P_{i-1}) / 6
C2 = P_{i+1} - (P_{i+2} - P_i) / 6

Tension = 0.5 (d3's default).

Theme model

theme.Theme is a pure-data struct — no methods. Cards pull t.Background, t.Text, t.Title, t.Accent, t.Muted, t.Stroke, t.StrokeOpacity. The 65 palettes live in a map keyed by snake_case ID.

Light themes (default, github, nord_bright, etc.) use StrokeOpacity: 1 with a visible stroke color; dark themes often use StrokeOpacity: 0 or a stroke that blends into the background.

Failure modes

Fault Behavior
Empty -user Exit 2, usage printed
Unknown theme Exit 2, suggests -list-themes
GraphQL 4xx/5xx Error wrapped with HTTP status and truncated (UTF-8-safe) body
Primary rate limit (429 / 403 + remaining=0) Sleep up to 5 min honoring Retry-After / X-RateLimit-Reset, retry once; longer windows surface as error
Per-year query returns nil user Warn to stderr; other years still contribute
FetchProductive network error Warn to stderr; partial data rendered
Unknown timezone Warn to stderr; fall back to UTC
Overall timeout (-timeout) or Ctrl-C ctx cancels in-flight requests; partial data may render
User with 0 commits Card renders "No data available"

Web UI mode

ghglance -serve :8080 runs internal/web (stdlib net/http, html/template, embed; vanilla JS, no build step) instead of the one-shot CLI path.

POST /generate ─► validate ─► rate limit / cooldown ─► Queue (dedup per user)
                                                          │  -workers goroutines
                                                          ▼
                              github.Collect ─► Store.Publish (every theme)
                                                          │
GET /u/{user} ◄── meta.json + card list ◄─────────────────┘
GET /u/{user}/{theme}/{card}.svg ◄── os.Root read
  • Storage. <data>/<user> (lowercased login) is a symlink into <data>/.gen/<user>-<nanos>/, which holds <theme>/<card>.svg plus meta.json (generated time, options, public/private scope; never the token). Publish renders into a fresh generation directory, then renames a new symlink over the old one, so a reader sees the old set or the new set, never a partial one. Startup sweeps generations no link points at. Sets older than -retention (default 24h) are deleted at startup and hourly; a store mutex keeps that removal from racing a republish.
  • Path safety. Logins are checked against GitHub's rule (alphanumerics and single hyphens, 1–39 chars) and themes and card names against the registered lists before any path is built; reads go through os.Root.
  • Jobs. In-process queue, at most one queued or running job per user, -workers concurrent, -timeout each, capacity 64. SIGTERM stops the HTTP server, cancels running jobs and drops queued ones; nothing is published mid-render.
  • Tokens. GitHub folds every private contribution a token can see into totals and calendars, so repo filters alone cannot keep cards public. Each job first identifies its token (viewer query: login, a one-repo privacy: PRIVATE probe, and a classic token's X-OAuth-Scopes). Token-less jobs use the server's GITHUB_TOKEN (identified once and cached) with private and org-repo scope forced off; they are refused when that token can read private repos, and for the token owner's own login. A submitter's token keeps its scope and skips the cooldown only for its own login; for anyone else it is refused if private-capable, otherwise forced to public scope under the cooldown. It lives only on the job and is cleared when it ends.
  • Partial fetches. The web path runs github.Collect with Strict, so a failed all-time or commit-history stage fails the job, and a job whose deadline passed is failed even if the fetch returned. The CLI keeps rendering partial data with warnings.
  • HTTP hardening. Strict CSP on pages, default-src 'none' + sandbox on SVGs, nosniff, 16 KiB form limit, http.CrossOriginProtection on the POST, per-client token bucket (burst 5, +1 per 2 min) keyed by IPv4 address or IPv6 /64, capped at 10,000 tracked clients.

Extension points

  • New card: implement Card interface, add to allCards in card.go.
  • New theme: add entry to themes map in theme.go.
  • New fetcher mode (e.g., REST per-commit): add a new method on *Client, call from github.Collect, wire to new Profile fields.