Skip to content
quarry Artifact [f68ee011c4]
Login

Artifact [f68ee011c4]

Artifact f68ee011c45124ed51467383e1b0e5d12cdb96031ad4da266d7e69e43110bb2b:


package web

import (
	"net/url"
	"sort"
	"strconv"
	"strings"
	"time"

	"craftdesign.group/quarry/internal/obs"
)

// The people page, built for a forge with many accounts: one line per
// person, a search, filters with counts, and every action on an account
// in its ⋯ menu rather than in its row. The agents and the people signed
// in now are filters of the same list rather than tables under it.
//
// Everything is a link or a GET form, so the page works without script;
// the menus are popovers, which need none either.

// peopleViews are the filters, in the order the page shows them.
var peopleViews = []struct{ key, label string }{
	{"all", "Everyone"}, {"admins", "Admins"}, {"agents", "Agents"},
	{"invited", "Invited"}, {"disabled", "Disabled"}, {"live", "Signed in now"},
}

// fleetPeopleView is a page of people, ready for the template.
type fleetPeopleView struct {
	View, Q, Sort string
	Views         []peopleTab
	Counts        map[string]int
	Sorts         []trLink
	People        []betaAccount
	Groups        []sessionGroup
	Total, Shown  int
	More          string // the link that shows more, "" when all are shown
	Lapsed        []string
	LapsedShow    []string // the first few names, for the sentence
	LapsedMore    int      // how many more the sentence does not name
	Unanswered    int
	Stale         []sessionGroup
	StaleIDs      []string
	This          string
	Now           time.Time
}

// peopleTab is one filter with its count.
type peopleTab struct {
	Label, Href string
	On          bool
	N           int
}

// sessionGroup is the sessions of one person on one device from one
// network: an agent that signs in for every task and never signs out is
// one line, not a page of them.
type sessionGroup struct {
	Account, Device, Network string
	IDs                      []string
	Mine                     bool
	Created, LastSeen        string // the oldest sign-in, the newest activity
}

// N is how many sessions the group holds.
func (g sessionGroup) N() int { return len(g.IDs) }

func peopleParams(q url.Values) (view, search, sortBy string, n int) {
	view = q.Get("view")
	ok := false
	for _, v := range peopleViews {
		ok = ok || v.key == view
	}
	if !ok {
		view = "all"
	}
	search = strings.ToLower(strings.TrimSpace(q.Get("q")))
	sortBy = q.Get("sort")
	if sortBy != "name" {
		sortBy = "seen"
	}
	n, _ = strconv.Atoi(q.Get("n"))
	if n < 25 {
		n = 25
	}
	return view, search, sortBy, min(n, 1000)
}

func peopleHref(view, search, sortBy string, n int) string {
	v := url.Values{}
	if view != "all" {
		v.Set("view", view)
	}
	if search != "" {
		v.Set("q", search)
	}
	if sortBy != "seen" {
		v.Set("sort", sortBy)
	}
	if n > 25 {
		v.Set("n", strconv.Itoa(n))
	}
	if len(v) == 0 {
		return "/fleet/people"
	}
	return "/fleet/people?" + v.Encode()
}

// buildPeoplePage filters, sorts and pages the accounts and sessions.
// forge holds the forge agents with their token counts filled in.
func buildPeoplePage(accts, forge []betaAccount, sessions []sessionRow, this string, q url.Values, now time.Time) fleetPeopleView {
	view, search, sortBy, n := peopleParams(q)
	p := fleetPeopleView{View: view, Q: search, Sort: sortBy, This: this, Now: now, Counts: map[string]int{}}
	tokens := map[string]betaAccount{}
	for _, a := range forge {
		tokens[a.Name] = a
	}
	for i := range accts {
		if f, ok := tokens[accts[i].Name]; ok {
			accts[i].Tokens, accts[i].TokenUsed = f.Tokens, f.TokenUsed
		}
	}
	in := map[string]func(betaAccount) bool{
		"all":      func(betaAccount) bool { return true },
		"admins":   func(a betaAccount) bool { return a.Admin },
		"agents":   func(a betaAccount) bool { return a.Operator != "" },
		"invited":  func(a betaAccount) bool { return a.Pending && a.Disabled == "" },
		"disabled": func(a betaAccount) bool { return a.Disabled != "" },
	}
	for k, f := range in {
		for _, a := range accts {
			if f(a) {
				p.Counts[k]++
			}
		}
	}
	groups := groupSessions(sessions, this)
	p.Counts["live"] = len(sessions)
	for _, v := range peopleViews {
		p.Views = append(p.Views, peopleTab{Label: v.label, Href: peopleHref(v.key, search, sortBy, 25), On: v.key == view, N: p.Counts[v.key]})
	}
	p.Sorts = []trLink{{Label: "last seen", Href: peopleHref(view, search, "seen", n), On: sortBy == "seen"},
		{Label: "name", Href: peopleHref(view, search, "name", n), On: sortBy == "name"}}
	match := func(s ...string) bool {
		if search == "" {
			return true
		}
		for _, x := range s {
			if strings.Contains(strings.ToLower(x), search) {
				return true
			}
		}
		return false
	}
	if view == "live" {
		var gs []sessionGroup
		for _, g := range groups {
			if match(g.Account, g.Device, g.Network) {
				gs = append(gs, g)
			}
		}
		for _, g := range groups {
			// Many sessions on one device that nobody ends: an agent
			// signing in for each task. Named above the list, with one
			// button that ends them.
			if g.N() > 3 && !g.Mine {
				p.Stale = append(p.Stale, g)
				p.StaleIDs = append(p.StaleIDs, g.IDs...)
			}
		}
		p.Total = len(gs)
		p.Groups = gs[:min(n, len(gs))]
		p.Shown = len(p.Groups)
	} else {
		var ps []betaAccount
		for _, a := range accts {
			if in[view](a) && match(a.Name, a.Email) {
				ps = append(ps, a)
			}
		}
		switch {
		case view == "invited":
			sort.SliceStable(ps, func(i, j int) bool { return ps[i].Created < ps[j].Created })
		case sortBy == "name":
			sort.SliceStable(ps, func(i, j int) bool { return ps[i].Name < ps[j].Name })
		default:
			sort.SliceStable(ps, func(i, j int) bool { return seenOf(ps[i]) > seenOf(ps[j]) })
		}
		p.Total = len(ps)
		p.People = ps[:min(n, len(ps))]
		p.Shown = len(p.People)
	}
	if p.Total > p.Shown {
		p.More = peopleHref(view, search, sortBy, n+25)
	}
	nowS := now.UTC().Format(time.RFC3339)
	for _, a := range accts {
		if a.Pending && a.InviteExpires != "" && a.InviteExpires < nowS {
			p.Lapsed = append(p.Lapsed, a.Name)
		}
		if a.Operator == "forge" && a.Answerable == "" {
			p.Unanswered++
		}
	}
	sort.Strings(p.Lapsed)
	p.LapsedShow = p.Lapsed[:min(4, len(p.Lapsed))]
	p.LapsedMore = len(p.Lapsed) - len(p.LapsedShow)
	return p
}

// seenOf is when an account was last seen, or signed in when it never
// was seen since: what "last seen" sorts by.
func seenOf(a betaAccount) string {
	if a.LastSeen != "" {
		return a.LastSeen
	}
	return a.LastLogin
}

func groupSessions(sessions []sessionRow, this string) []sessionGroup {
	var out []sessionGroup
	at := map[string]int{}
	for _, s := range sessions {
		dev := deviceName(s.Agent)
		netName := obs.Default.NetworkName(s.IP)
		if netName == "" {
			netName = s.IP
		}
		k := s.Account + "\x00" + dev + "\x00" + netName
		i, ok := at[k]
		if !ok {
			i = len(out)
			at[k] = i
			out = append(out, sessionGroup{Account: s.Account, Device: dev, Network: netName, Created: s.Created})
		}
		g := &out[i]
		g.IDs = append(g.IDs, s.ID)
		g.Mine = g.Mine || s.ID == this
		if s.Created < g.Created {
			g.Created = s.Created
		}
		if s.LastSeen > g.LastSeen {
			g.LastSeen = s.LastSeen
		}
	}
	sort.SliceStable(out, func(i, j int) bool { return out[i].LastSeen > out[j].LastSeen })
	return out
}

// inviteLeft says how long a pending account's invite link has left,
// "" once it has lapsed.
func inviteLeft(expires string, now time.Time) string {
	t, err := time.Parse(time.RFC3339, expires)
	if err != nil || !t.After(now) {
		return ""
	}
	days := int(t.Sub(now).Hours()/24) + 1
	if days == 1 {
		return "1 day left"
	}
	return strconv.Itoa(days) + " days left"
}