package web
import (
"log"
"sort"
"strings"
)
// Reader traffic: who cloned and fetched a repository, with what, and
// how much of that is knowable at all.
//
// The page exists because a clone is the one kind of forge activity
// that leaves no trace in the DAG — it reads and writes nothing — so
// without a tally it is invisible. The counters behind it are written
// by the sync gate (internal/xfer) and read here.
//
// It is an ADMIN page, unlike the bare clone count on the repository's
// front page. The difference is not the number, it is the names: how
// many people cloned a public repository is public information, while
// which accounts did is the repository's business.
// trafficView is one repository's reader traffic, ready to render.
type trafficView struct {
Repo string
// Clones and Fetches are the running totals. A clone is a new
// clone; a fetch is a later sync round, of which one clone
// produces many.
Clones, Fetches int
// Identified is how many clones named a verified account. It is
// stated next to the total because the ratio is the honest headline
// — on a public repository most clones name nobody, and a page that
// hid that would imply the opposite.
Identified int
// Clients is the software population, most-used first.
Clients []trafficRow
// Readers is the identity breakdown: verified accounts, then
// claimed names, then the unidentified remainder. Never sorted into
// one another — see trafficRow.Kind.
Readers []trafficRow
// Unidentified is the count of clones that named nobody. Carried
// separately as well as in Readers so the empty state can speak.
Unidentified int
}
// trafficRow is one bar: a label, a count, and a width relative to the
// largest row in its own group.
type trafficRow struct {
Label string
Count int
// Pct is the bar width, 0..100, relative to the biggest row in the
// same group. It is a SHAPE, not a share of the total: with one
// row at 293 and the rest in single figures, shares would render
// every real reader as an invisible sliver.
Pct int
// Kind is "auth", "claim" or "anon". The template shows a claimed
// name differently and never merges it with a verified one: nothing
// signs a pull, so a claim is free to forge, and a page that drew
// them alike would report a forgery as a fact.
Kind string
}
// Claimed reports a row naming somebody nothing verified.
func (r trafficRow) Claimed() bool { return r.Kind == "claim" }
// Anon reports the row standing for everyone who named nobody.
func (r trafficRow) Anon() bool { return r.Kind == "anon" }
// TallyFamilies reads a whole counter family. It is separate from
// Tallies' single-counter Total because a forge without it still
// renders every number that existed before — the traffic page is the
// only thing that needs the enumeration, and it degrades to empty.
type TallyFamilies interface {
Totals(repo, prefix string) (map[string]int64, error)
}
// familiesOf returns one family's counters, or nothing. A counter plane
// that errors renders no breakdown rather than a wrong one: the totals
// beside it are still true.
func familiesOf(t Tallies, repo, prefix string) map[string]int64 {
f, ok := t.(TallyFamilies)
if !ok || t == nil {
return nil
}
m, err := f.Totals(repo, prefix)
if err != nil {
log.Printf("web: traffic %s/%s: %v", repo, prefix, err)
return nil
}
return m
}
// trafficFor assembles the panel for one repository and one verb —
// "clone" or "fetch".
func trafficFor(t Tallies, repo, verb string) trafficView {
v := trafficView{Repo: repo}
v.Clones = tallyOf(t, repo, "clone")
v.Fetches = tallyOf(t, repo, "fetch")
v.Clients = clientRows(familiesOf(t, repo, verb+".client"))
auth := familiesOf(t, repo, verb+".auth")
claim := familiesOf(t, repo, verb+".claim")
anon := tallyOf(t, repo, verb+".anon")
for _, n := range auth {
v.Identified += int(n)
}
v.Unidentified = anon
v.Readers = readerRows(auth, claim, anon)
return v
}
// clientRows turns "zit.22900" keys into "zit 22900" bars. The two
// halves are stored in one subject because they are one fact — this
// build of this client — and split here because that is where a reader
// wants to see them.
func clientRows(m map[string]int64) []trafficRow {
rows := make([]trafficRow, 0, len(m))
for k, n := range m {
kind, version, _ := strings.Cut(k, ".")
label := kind
// "_" is the sanitizer's stand-in for a name that was empty —
// here, a client that sent no version pragma at all.
if version != "" && version != "_" {
label = kind + " " + version
}
rows = append(rows, trafficRow{Label: label, Count: int(n)})
}
return widen(rank(rows))
}
// readerRows lays the three identity states out in one list, in the
// order a reader should take them: proven names first, then asserted
// ones, then everybody who said nothing.
func readerRows(auth, claim map[string]int64, anon int) []trafficRow {
var rows []trafficRow
for k, n := range auth {
rows = append(rows, trafficRow{Label: k, Count: int(n), Kind: "auth"})
}
rank(rows)
var claims []trafficRow
for k, n := range claim {
claims = append(claims, trafficRow{Label: k, Count: int(n), Kind: "claim"})
}
rows = append(rows, rank(claims)...)
if anon > 0 {
rows = append(rows, trafficRow{Label: "unidentified", Count: anon, Kind: "anon"})
}
// The widths span the whole list, including the unidentified row,
// because that row is the one that gives the others their scale.
return widen(rows)
}
// rank orders rows by count, then by label so equal counts do not
// shuffle between renders of the same numbers. Widths are set
// separately, because a group's rows are not always ranked against the
// same set they are drawn against.
func rank(rows []trafficRow) []trafficRow {
sort.Slice(rows, func(i, j int) bool {
if rows[i].Count != rows[j].Count {
return rows[i].Count > rows[j].Count
}
return rows[i].Label < rows[j].Label
})
return rows
}
// widen sets each bar relative to the largest row present.
func widen(rows []trafficRow) []trafficRow {
most := 0
for _, r := range rows {
if r.Count > most {
most = r.Count
}
}
if most == 0 {
return rows
}
for i := range rows {
rows[i].Pct = rows[i].Count * 100 / most
// A row that exists is drawn. Rounding a real reader down to a
// zero-width bar says "none", which is a different claim.
if rows[i].Pct == 0 && rows[i].Count > 0 {
rows[i].Pct = 1
}
}
return rows
}
// fleetTrafficRow is one repository's line on the forge-wide page.
type fleetTrafficRow struct {
Repo string
Clones, Fetches, Identified int
// Pct is the clone bar, relative to the busiest repository.
Pct int
}
// fleetTraffic ranks every repository by clones served.
//
// The forge admin's question is a different one from the repository
// admin's: not "who cloned mine" but "what is anyone reading at all",
// which is answered by the shape of the list rather than by any single
// row. Names stay off this page — a forge admin who wants them opens
// the repository's own traffic page, where the gate is the repository's.
func fleetTraffic(t Tallies, repos []string) []fleetTrafficRow {
rows := make([]fleetTrafficRow, 0, len(repos))
most := 0
for _, name := range repos {
row := fleetTrafficRow{
Repo: name,
Clones: tallyOf(t, name, "clone"),
Fetches: tallyOf(t, name, "fetch"),
}
for _, n := range familiesOf(t, name, "clone.auth") {
row.Identified += int(n)
}
if row.Clones == 0 && row.Fetches == 0 {
// A repository nobody has read is a true row and a useless
// one; the list is long enough without it.
continue
}
if row.Clones > most {
most = row.Clones
}
rows = append(rows, row)
}
sort.Slice(rows, func(i, j int) bool {
if rows[i].Clones != rows[j].Clones {
return rows[i].Clones > rows[j].Clones
}
if rows[i].Fetches != rows[j].Fetches {
return rows[i].Fetches > rows[j].Fetches
}
return rows[i].Repo < rows[j].Repo
})
for i := range rows {
if most > 0 {
rows[i].Pct = rows[i].Clones * 100 / most
if rows[i].Pct == 0 && rows[i].Clones > 0 {
rows[i].Pct = 1
}
}
}
return rows
}