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"
}