From c6ccd3530d213749800e70d09ea93c57d21da8ea Mon Sep 17 00:00:00 2001 From: Ivan Kolesnikov <81024464+prettyleaf@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:32:02 +0700 Subject: [PATCH] feat: styles improvement and repo info --- .github/workflows/build-dev.yml | 4 + .github/workflows/docker.yml | 14 ++- Dockerfile | 10 +- Makefile | 15 ++- README.md | 9 ++ README.ru.md | 8 ++ cmd/gh-proxy/main.go | 35 +++++- internal/server/server.go | 7 +- internal/server/server_test.go | 3 +- internal/status/page.html | 201 ++++++++++++++++++++++++++++++-- internal/status/status.go | 37 ++++-- 11 files changed, 310 insertions(+), 33 deletions(-) diff --git a/.github/workflows/build-dev.yml b/.github/workflows/build-dev.yml index 5a6deb6..c873a57 100644 --- a/.github/workflows/build-dev.yml +++ b/.github/workflows/build-dev.yml @@ -115,6 +115,10 @@ jobs: org.opencontainers.image.created=${{ steps.meta.outputs.build_time }} build-args: | VERSION=${{ steps.meta.outputs.version }} + COMMIT=${{ steps.meta.outputs.sha_short }} + BRANCH=${{ github.ref_name }} + BUILD_TIME=${{ steps.meta.outputs.build_time }} + BUILD_NUMBER=${{ github.run_number }} cache-from: type=gha cache-to: type=gha,mode=max # Keeps the package listing to the two real platforms instead of diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 615cf43..f48ebfa 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -41,6 +41,13 @@ jobs: type=ref,event=branch type=sha,format=short + # What the status page's build popover shows, alongside the version. + - name: Prepare build metadata + id: build + run: | + echo "sha_short=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT" + echo "build_time=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT" + - name: Build and push uses: docker/build-push-action@v6 with: @@ -49,7 +56,12 @@ jobs: push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} - build-args: VERSION=${{ steps.meta.outputs.version }} + build-args: | + VERSION=${{ steps.meta.outputs.version }} + COMMIT=${{ steps.build.outputs.sha_short }} + BRANCH=${{ github.ref_name }} + BUILD_TIME=${{ steps.build.outputs.build_time }} + BUILD_NUMBER=${{ github.run_number }} cache-from: type=gha cache-to: type=gha,mode=max # Keeps the package listing to the two real platforms instead of diff --git a/Dockerfile b/Dockerfile index 27af81e..7c60aea 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,11 +13,19 @@ COPY cmd ./cmd COPY internal ./internal ARG VERSION=dev +# Provenance for the status page's build popover. All optional: an unset one +# just leaves that row off the page. +ARG COMMIT= +ARG BRANCH= +ARG BUILD_TIME= +ARG BUILD_NUMBER= ARG TARGETOS ARG TARGETARCH RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build \ -trimpath \ - -ldflags "-s -w -X main.version=${VERSION}" \ + -ldflags "-s -w -X main.version=${VERSION} -X main.commit=${COMMIT} \ + -X main.branch=${BRANCH} -X main.buildTime=${BUILD_TIME} \ + -X main.buildNumber=${BUILD_NUMBER}" \ -o /out/gh-proxy ./cmd/gh-proxy # scratch: no shell, no package manager, nothing to pivot to if the proxy is diff --git a/Makefile b/Makefile index e532337..ccea95f 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,13 @@ VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev) -LDFLAGS := -s -w -X main.version=$(VERSION) +# The rest of what the status page's build popover shows. BUILD_NUMBER is a CI +# run number and stays empty locally; the page hides the rows it has no value for. +COMMIT ?= $(shell git rev-parse --short HEAD 2>/dev/null) +BRANCH ?= $(shell git rev-parse --abbrev-ref HEAD 2>/dev/null) +BUILD_TIME ?= $(shell date -u +%Y-%m-%dT%H:%M:%SZ) +BUILD_NUMBER ?= +LDFLAGS := -s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) \ + -X main.branch=$(BRANCH) -X main.buildTime=$(BUILD_TIME) \ + -X main.buildNumber=$(BUILD_NUMBER) .PHONY: build test race vet lint tidy run docker up down logs token clean @@ -32,7 +40,10 @@ run: build GHP_STATUS_PATH=$$STATUS ./bin/gh-proxy docker: - docker build --build-arg VERSION=$(VERSION) -t gh-proxy:$(VERSION) -t gh-proxy:latest . + docker build --build-arg VERSION=$(VERSION) --build-arg COMMIT=$(COMMIT) \ + --build-arg BRANCH=$(BRANCH) --build-arg BUILD_TIME=$(BUILD_TIME) \ + --build-arg BUILD_NUMBER=$(BUILD_NUMBER) \ + -t gh-proxy:$(VERSION) -t gh-proxy:latest . up: docker compose up -d --build diff --git a/README.md b/README.md index 05d5e07..ce75920 100644 --- a/README.md +++ b/README.md @@ -152,6 +152,15 @@ GHP_STATUS_PATH=/ghp-status # off unless this is set `/ghp-status/json` (what the page polls, every 5 s) and `/ghp-status/metrics` (Prometheus text format) beside it. +The header carries the running version; clicking it opens the build behind it — +branch, commit, build time, CI run number — next to the repository's star count +and a refresh button for when five seconds is too long to wait. The version goes +amber and pulses when a newer release exists. Those two numbers are the only +thing on the page that comes from outside, and the *browser* fetches them +straight from api.github.com, cached for half an hour: the proxy itself makes no +such call, and a viewer who cannot reach GitHub gets the whole page anyway, +without the counts. + The path is a full path from the site root rather than something under `GHP_PREFIX`, so it is one `location` for the reverse proxy to guard: diff --git a/README.ru.md b/README.ru.md index 26f342f..1711d86 100644 --- a/README.ru.md +++ b/README.ru.md @@ -145,6 +145,14 @@ GHP_STATUS_PATH=/ghp-status # выключена, пока это не зад ней — `/ghp-status/json` (её и опрашивает сама страница, раз в 5 с) и `/ghp-status/metrics` (текстовый формат Prometheus). +В шапке — версия, с которой запущен процесс; по клику раскрывается сборка за ней: +ветка, коммит, время сборки, номер прогона CI. Рядом счётчик звёзд репозитория и +кнопка обновления, когда ждать пять секунд до следующего опроса долго. Версия +становится жёлтой и слегка пульсирует, если вышел релиз новее. Эти два числа — +единственное на странице, что берётся снаружи, и запрашивает их *браузер* прямо +у api.github.com с получасовым кэшем: сам прокси никуда не ходит, а тот, у кого +нет доступа к GitHub, получает страницу целиком, только без счётчиков. + Путь задаётся от корня сайта, а не внутри `GHP_PREFIX`, — чтобы у reverse-proxy был ровно один `location`, который можно закрыть: diff --git a/cmd/gh-proxy/main.go b/cmd/gh-proxy/main.go index 47c69ad..e349509 100644 --- a/cmd/gh-proxy/main.go +++ b/cmd/gh-proxy/main.go @@ -21,8 +21,31 @@ import ( "github.com/prettyleaf/gh-proxy/internal/status" ) -// version is overridden at build time with -ldflags "-X main.version=...". -var version = "dev" +// Build metadata, all of it overridden at build time with +// -ldflags "-X main.=...". Everything but the version is empty in a plain +// `go build`, and the status page hides the rows it has no value for. +var ( + version = "dev" + commit = "" + branch = "" + buildTime = "" // RFC 3339, UTC + buildNumber = "" // CI run number + // repo is the GitHub repository the status page's header links to, for the + // star count and the latest release. A fork overrides it the same way. + repo = "prettyleaf/gh-proxy" +) + +// buildInfo is what the header's version chip and its popover display. +func buildInfo() status.Build { + return status.Build{ + Version: version, + Commit: commit, + Branch: branch, + Time: buildTime, + Number: buildNumber, + Repo: repo, + } +} func main() { // The scratch-based image has no shell and no curl, so the binary probes @@ -97,7 +120,7 @@ func run() error { public := &http.Server{ Addr: cfg.Listen, - Handler: server.New(cfg, p, m, log), + Handler: server.New(cfg, p, m, buildInfo(), log), // No WriteTimeout on purpose: it is an absolute deadline on the whole // response, which would sever long release downloads and idle git // fetches. ReadHeaderTimeout covers the slowloris case instead. @@ -108,7 +131,7 @@ func run() error { admin := &http.Server{ Addr: cfg.AdminListen, - Handler: adminHandler(m, info), + Handler: adminHandler(m, info, buildInfo()), ReadHeaderTimeout: 5 * time.Second, ErrorLog: slog.NewLogLogger(log.Handler(), slog.LevelDebug), } @@ -167,14 +190,14 @@ func serve(s *http.Server, name string, errCh chan<- error) { // The status page and the metrics live here unconditionally: this listener is // bound to loopback, so reaching it already means being on the host. The public // copy under GHP_STATUS_PATH is the one that has to be configured on purpose. -func adminHandler(m *metrics.Metrics, info status.Info) http.Handler { +func adminHandler(m *metrics.Metrics, info status.Info, b status.Build) http.Handler { mux := http.NewServeMux() mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "text/plain; charset=utf-8") _, _ = fmt.Fprintf(w, "ok %s\n", version) }) - h := status.New(m, info) + h := status.New(m, info, b) // Both patterns, so ServeMux answers "/status" itself instead of bouncing // it to "/status/" with a redirect. mux.Handle("/status", http.StripPrefix("/status", h)) diff --git a/internal/server/server.go b/internal/server/server.go index 6392b80..d51ccfd 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -31,8 +31,9 @@ type Server struct { } // New builds the public handler. m may be nil, in which case nothing is -// counted and the status page is not served. -func New(cfg *config.Config, p *proxy.Proxy, m *metrics.Metrics, log *slog.Logger) *Server { +// counted and the status page is not served. b is the build metadata the status +// page's header displays. +func New(cfg *config.Config, p *proxy.Proxy, m *metrics.Metrics, b status.Build, log *slog.Logger) *Server { if log == nil { log = slog.Default() } @@ -46,7 +47,7 @@ func New(cfg *config.Config, p *proxy.Proxy, m *metrics.Metrics, log *slog.Logge if m != nil && cfg.StatusPath != "" { // Mounted with its own path stripped, so the handler only ever sees // "", "/json" or "/metrics" and does not need to know where it lives. - s.status = http.StripPrefix(cfg.StatusPath, status.New(m, StatusInfo(cfg))) + s.status = http.StripPrefix(cfg.StatusPath, status.New(m, StatusInfo(cfg), b)) } return s } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 6e9dca6..d1d92fe 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -18,6 +18,7 @@ import ( "github.com/prettyleaf/gh-proxy/internal/metrics" "github.com/prettyleaf/gh-proxy/internal/proxy" "github.com/prettyleaf/gh-proxy/internal/server" + "github.com/prettyleaf/gh-proxy/internal/status" ) const ( @@ -125,7 +126,7 @@ func newHandler(t *testing.T, f *fakeGitHub, mutate func(*config.Config)) http.H BaseTransport: f.transport(), Logger: discardLogger(), }) - return server.New(cfg, p, metrics.New("test"), discardLogger()) + return server.New(cfg, p, metrics.New("test"), status.Build{Version: "test"}, discardLogger()) } // do runs one request through the handler. diff --git a/internal/status/page.html b/internal/status/page.html index d2d5ce4..26967e4 100644 --- a/internal/status/page.html +++ b/internal/status/page.html @@ -27,15 +27,46 @@ font:14px/1.5 system-ui,-apple-system,"Segoe UI",sans-serif; } .wrap{max-width:1040px;margin:0 auto} -header{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap;margin-bottom:20px} +header{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:20px} h1{font-size:19px;font-weight:600;margin:0;letter-spacing:-.01em} -h1 .ver{color:var(--muted);font-weight:400;font-size:13px;margin-left:6px} header .spacer{flex:1} -header .live{display:flex;align-items:center;gap:6px;color:var(--ink2);font-size:12px} -header .live::before{ - content:"";width:8px;height:8px;border-radius:50%;background:var(--good); -} -header .live.stale::before{background:var(--crit)} +.live{display:flex;align-items:center;gap:6px;color:var(--ink2);font-size:12px;height:30px} +.live::before{content:"";width:8px;height:8px;border-radius:50%;background:var(--good)} +.live.stale::before{background:var(--crit)} + +/* Header controls. The version chip owns the version now, which is why the + heading no longer carries it. */ +.tools{display:flex;align-items:center;gap:8px;position:relative} +.pill{display:inline-flex;align-items:center;gap:7px;height:30px;padding:0 12px; + border:1px solid var(--border);border-radius:999px;background:var(--surface); + color:var(--ink);font:inherit;font-size:12px;line-height:1;text-decoration:none; + cursor:pointer;white-space:nowrap;transition:border-color .12s,color .12s} +.pill:hover{border-color:var(--axis)} +.pill:focus-visible{outline:2px solid var(--series);outline-offset:2px} +.pill svg{width:15px;height:15px;flex:none;display:block} +.pill .n{font-variant-numeric:tabular-nums} +.pill.round{padding:0 9px} +/* A release waiting on GitHub is the one thing in this header worth a colour + and a heartbeat; a current build stays plain. */ +#build-btn.outdated{color:var(--warn);border-color:color-mix(in srgb,var(--warn) 45%,transparent); + animation:pulse 2.6s ease-in-out infinite} +@keyframes pulse{0%,100%{opacity:1}50%{opacity:.5}} +#refresh.busy svg{animation:spin .7s linear infinite} +@keyframes spin{to{transform:rotate(360deg)}} +@media (prefers-reduced-motion:reduce){#build-btn.outdated,#refresh.busy svg{animation:none}} +.pop{position:absolute;top:calc(100% + 8px);right:0;z-index:9;min-width:252px; + max-width:min(320px,calc(100vw - 40px));background:var(--surface); + border:1px solid var(--border);border-radius:10px;padding:14px 16px; + box-shadow:0 10px 30px rgba(0,0,0,.18)} +.pop h3{margin:0 0 10px;font-size:11px;font-weight:600;letter-spacing:.04em; + text-transform:uppercase;color:var(--ink2)} +.pop dl{gap:6px 16px;font-size:12.5px} +.pop dd{text-align:right;font-variant-numeric:tabular-nums} +.pop .note{margin:12px 0 0;font-size:12px;color:var(--ink2)} +.pop .note.outdated{color:var(--warn)} +.pop .go{display:block;margin-top:10px;padding:7px 12px;border:1px solid var(--border); + border-radius:8px;color:var(--ink);text-decoration:none;font-size:12px;text-align:center} +.pop .go:hover{border-color:var(--axis)} h2{font-size:12px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--ink2);margin:0 0 12px} h2 .sub{text-transform:none;letter-spacing:0;font-weight:400;color:var(--muted);margin-left:8px} .card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:16px 18px;margin-bottom:16px} @@ -45,8 +76,12 @@ .tile .label{color:var(--ink2);font-size:12px} .tile .value{font-size:26px;font-weight:600;line-height:1.25;margin-top:2px} .tile .note{color:var(--muted);font-size:11px} -.cols{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px} -.cols .card{margin-bottom:16px} +/* Two columns, never three. There are exactly four cards here, so an auto-fit + grid that fits three of them leaves the fourth alone in a row with two thirds + of the page empty beside it. */ +.cols{display:grid;grid-template-columns:1fr;gap:16px;margin-bottom:16px} +@media (min-width:700px){.cols{grid-template-columns:repeat(2,minmax(0,1fr))}} +.cols .card{margin-bottom:0} .rows{display:flex;flex-direction:column;gap:9px} .row{display:grid;grid-template-columns:1fr auto;gap:2px 10px;align-items:baseline} .row .name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--ink2)} @@ -87,9 +122,45 @@
-

gh-proxy

+

gh-proxy

connecting
+
+ + + + + — + + + + + +
@@ -324,6 +395,114 @@

Configuration

} const hideTip = () => { $("tip").style.opacity = 0; }; +/* ---------- header: build info, update check, stars ---------- */ +// GitHub is asked from the browser, not from the proxy: the operator's server +// makes no outbound call it was not configured to make, and an instance whose +// viewers cannot reach api.github.com simply shows no counts. Anonymous callers +// get 60 requests an hour, so the answer is cached per repository. +const GH_TTL = 30 * 60 * 1000; +let build = null; + +function buildChip(b){ + build = b || {}; + $("build-version").textContent = build.version || "unknown"; + const rows = [ + ["Version", build.version], + ["Branch", build.branch], + ["Commit", build.commit], + ["Built", build.time ? stamp(build.time) : ""], + ["Build", build.number ? "#" + build.number : ""], + ].filter(([, v]) => v); + $("build-rows").innerHTML = rows.map(([k, v]) => `
${k}
${esc(v)}
`).join(""); + if (build.repo){ + $("stars").href = "https://github.com/" + build.repo; + $("build-release").href = "https://github.com/" + build.repo + "/releases/latest"; + } +} +const stamp = (iso) => { const d = new Date(iso); return isNaN(+d) ? iso : d.toLocaleString("en-GB"); }; + +// Versions come from `git describe`, so "1.4.2", "v1.4.2-3-gdeadbee-dirty" and +// "dev-deadbee" all turn up here. Only a release-shaped one can be compared; +// anything else means "no opinion", never "out of date". +function semver(v){ + const m = /^v?(\d+)\.(\d+)\.(\d+)/.exec(String(v || "").trim()); + return m ? [+m[1], +m[2], +m[3]] : null; +} +function behind(here, there){ + for (let i = 0; i < 3; i++) if (here[i] !== there[i]) return here[i] < there[i]; + return false; +} + +async function ghMeta(force){ + if (!build || !build.repo) return null; + const key = "ghp:gh:" + build.repo; + let cached = null; + try { cached = JSON.parse(localStorage.getItem(key) || "null"); } catch (e) {} + if (!force && cached && Date.now() - cached.at < GH_TTL) return cached; + + const base = "https://api.github.com/repos/" + build.repo; + const get = async (u) => { + try { + const r = await fetch(u, {headers:{Accept:"application/vnd.github+json"}}); + return r.ok ? await r.json() : null; + } catch (e) { return null; } + }; + const [repo, rel] = await Promise.all([get(base), get(base + "/releases/latest")]); + // Offline, rate-limited, or a repository with no releases yet: keep whatever + // was known rather than blanking the header. + if (!repo && !rel) return cached; + const fresh = { + at: Date.now(), + stars: repo ? repo.stargazers_count : cached && cached.stars, + tag: rel ? rel.tag_name : cached && cached.tag, + url: rel ? rel.html_url : cached && cached.url, + }; + try { localStorage.setItem(key, JSON.stringify(fresh)); } catch (e) {} + return fresh; +} + +async function checkGitHub(force){ + const btn = $("build-btn"), note = $("build-note"); + const g = await ghMeta(force); + if (!g){ note.textContent = "GitHub unreachable."; return; } + + if (typeof g.stars === "number"){ + $("star-count").textContent = compact(g.stars); + $("stars").title = num(g.stars) + " stars on GitHub"; + } + if (g.url) $("build-release").href = g.url; + + const here = semver(build.version), there = semver(g.tag); + const old = here && there && behind(here, there); + btn.classList.toggle("outdated", !!old); + note.classList.toggle("outdated", !!old); + btn.title = old ? g.tag + " available" : "Build information"; + note.textContent = old ? g.tag + " is available." + : here && there ? "Up to date." + : g.tag ? "Latest release " + g.tag + "." + : "No releases published."; +} + +const pop = $("build-pop"); +function showPop(open){ + pop.hidden = !open; + $("build-btn").setAttribute("aria-expanded", open ? "true" : "false"); +} +$("build-btn").addEventListener("click", (e) => { e.stopPropagation(); showPop(pop.hidden); }); +pop.addEventListener("click", (e) => e.stopPropagation()); +document.addEventListener("click", () => showPop(false)); +document.addEventListener("keydown", (e) => { if (e.key === "Escape") showPop(false); }); + +$("refresh").addEventListener("click", async () => { + const b = $("refresh"); + if (b.classList.contains("busy")) return; + b.classList.add("busy"); + // The counters are re-read immediately; the GitHub half keeps its cache + // unless it has gone stale, so leaning on the button cannot burn the quota. + await Promise.all([tick(), checkGitHub(false)]); + setTimeout(() => b.classList.remove("busy"), 300); +}); + /* ---------- polling ---------- */ let failures = 0; function live(ok){ @@ -347,7 +526,7 @@

Configuration

let last = null; function render(d){ last = d; - $("version").textContent = " " + d.version; + if (!build){ buildChip(d.build || {version: d.version}); checkGitHub(false); } $("started").textContent = "up " + uptime(d.uptime_seconds) + " · since " + new Date(d.started_at).toLocaleString("en-GB"); document.title = "gh-proxy · " + compact(d.requests_total) + " req"; diff --git a/internal/status/status.go b/internal/status/status.go index 267ca26..6f3bb79 100644 --- a/internal/status/status.go +++ b/internal/status/status.go @@ -39,21 +39,41 @@ type Info struct { CORS bool `json:"cors"` } +// Build is where this binary came from: what the header's version chip shows, +// and what its popover expands into. Every field but the version is set with +// -ldflags at build time, so a plain `go build` leaves them empty and the page +// omits the rows. +// +// Repo is the GitHub repository the header links to for the star count and the +// latest release, as "owner/name". It is the only field the page sends anywhere: +// the browser asks api.github.com about it directly, so an instance with no +// outbound access from the operator's browser simply shows no counts. +type Build struct { + Version string `json:"version"` + Commit string `json:"commit,omitempty"` + Branch string `json:"branch,omitempty"` + Time string `json:"time,omitempty"` + Number string `json:"number,omitempty"` + Repo string `json:"repo,omitempty"` +} + // Handler answers the status path and everything under it. type Handler struct { - m *metrics.Metrics - info Info + m *metrics.Metrics + info Info + build Build } // New builds the handler. It expects to be mounted with the status path already // stripped, so r.URL.Path is "", "/json" or "/metrics". -func New(m *metrics.Metrics, info Info) *Handler { - return &Handler{m: m, info: info} +func New(m *metrics.Metrics, info Info, build Build) *Handler { + return &Handler{m: m, info: info, build: build} } type payload struct { metrics.Snapshot - Config Info `json:"config"` + Config Info `json:"config"` + Build Build `json:"build"` } func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { @@ -81,7 +101,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (h *Handler) writeJSON(w http.ResponseWriter) { // Rendered into a buffer first so a marshalling error cannot leave a // half-written 200 on the wire. - body, err := json.Marshal(payload{Snapshot: h.m.Snapshot(), Config: h.info}) + body, err := json.Marshal(payload{Snapshot: h.m.Snapshot(), Config: h.info, Build: h.build}) if err != nil { http.Error(w, "snapshot failed", http.StatusInternalServerError) return @@ -97,8 +117,9 @@ func (h *Handler) writeProm(w http.ResponseWriter) { s := h.m.Snapshot() var b bytes.Buffer - metric(&b, "ghproxy_build_info", "gauge", "Version of the running binary.") - fmt.Fprintf(&b, "ghproxy_build_info{version=%s} 1\n", quote(s.Version)) + metric(&b, "ghproxy_build_info", "gauge", "Provenance of the running binary.") + fmt.Fprintf(&b, "ghproxy_build_info{version=%s,commit=%s,branch=%s} 1\n", + quote(s.Version), quote(h.build.Commit), quote(h.build.Branch)) simple(&b, "ghproxy_uptime_seconds", "gauge", "Seconds since start.", s.Uptime) simple(&b, "ghproxy_requests_in_flight", "gauge", "Requests currently being served.", float64(s.InFlight))