Widen the administration interface
This commit is contained in:
+85
-18
@@ -2,6 +2,7 @@ package server
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"slices"
|
||||
"time"
|
||||
@@ -32,9 +33,19 @@ type adminObject struct {
|
||||
Size string
|
||||
Bytes int64
|
||||
Owner string
|
||||
Created string
|
||||
Expires string
|
||||
Vanity bool
|
||||
|
||||
// Times are kept three ways: the value itself, which is what sorting
|
||||
// compares; an absolute rendering for the tooltip; and a relative one,
|
||||
// which is what you actually read when deciding whether a file still
|
||||
// matters. Sorting on the rendered string would tie everything that
|
||||
// happened within the same minute.
|
||||
createdAt time.Time
|
||||
expiresAt *time.Time
|
||||
Created string
|
||||
CreatedAgo string
|
||||
Expires string
|
||||
ExpiresIn string
|
||||
}
|
||||
|
||||
type adminToken struct {
|
||||
@@ -48,13 +59,26 @@ type adminToken struct {
|
||||
// adminSorts maps the sort parameter to a comparison. Restricting to this set
|
||||
// keeps the parameter from reaching anything that interprets it.
|
||||
var adminSorts = map[string]func(a, b adminObject) int{
|
||||
"created": func(a, b adminObject) int { return cmp.Compare(b.Created, a.Created) },
|
||||
"expires": func(a, b adminObject) int { return cmp.Compare(a.Expires, b.Expires) },
|
||||
"created": func(a, b adminObject) int { return b.createdAt.Compare(a.createdAt) },
|
||||
"expires": func(a, b adminObject) int { return compareExpiry(a.expiresAt, b.expiresAt) },
|
||||
"size": func(a, b adminObject) int { return cmp.Compare(b.Bytes, a.Bytes) },
|
||||
"name": func(a, b adminObject) int { return cmp.Compare(a.ID, b.ID) },
|
||||
"name": func(a, b adminObject) int { return cmp.Compare(a.Filename, b.Filename) },
|
||||
"owner": func(a, b adminObject) int { return cmp.Compare(a.Owner, b.Owner) },
|
||||
}
|
||||
|
||||
// compareExpiry orders soonest first, with "never" last where it belongs.
|
||||
func compareExpiry(a, b *time.Time) int {
|
||||
switch {
|
||||
case a == nil && b == nil:
|
||||
return 0
|
||||
case a == nil:
|
||||
return 1
|
||||
case b == nil:
|
||||
return -1
|
||||
}
|
||||
return a.Compare(*b)
|
||||
}
|
||||
|
||||
func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
lim, err := s.limitsFor(r, credential(r))
|
||||
switch {
|
||||
@@ -87,20 +111,24 @@ func (s *Server) handleAdmin(w http.ResponseWriter, r *http.Request) {
|
||||
anonymous++
|
||||
}
|
||||
objects = append(objects, adminObject{
|
||||
ID: m.ID,
|
||||
Filename: m.Filename,
|
||||
Size: config.FormatBytes(m.Size),
|
||||
Bytes: m.Size,
|
||||
Owner: m.Owner,
|
||||
Created: m.Created.UTC().Format(time.RFC3339),
|
||||
Expires: expiresSortable(m.Expires),
|
||||
Vanity: m.Vanity,
|
||||
ID: m.ID,
|
||||
Filename: m.Filename,
|
||||
Size: config.FormatBytes(m.Size),
|
||||
Bytes: m.Size,
|
||||
Owner: m.Owner,
|
||||
Vanity: m.Vanity,
|
||||
createdAt: m.Created,
|
||||
expiresAt: m.Expires,
|
||||
Created: absolute(&m.Created),
|
||||
CreatedAgo: relative(m.Created, now),
|
||||
Expires: absolute(m.Expires),
|
||||
ExpiresIn: expiresRelative(m.Expires, now),
|
||||
})
|
||||
}
|
||||
slices.SortStableFunc(objects, adminSorts[sortBy])
|
||||
|
||||
data := adminPage{
|
||||
page: s.page(r, "Administration", true),
|
||||
page: s.widePage(r, "Administration"),
|
||||
Objects: objects,
|
||||
Tokens: s.adminTokens(),
|
||||
Sort: sortBy,
|
||||
@@ -139,11 +167,50 @@ func (s *Server) adminTokens() []adminToken {
|
||||
return out
|
||||
}
|
||||
|
||||
// expiresSortable renders a deadline so that string ordering is chronological
|
||||
// and "never" sorts last.
|
||||
func expiresSortable(t *time.Time) string {
|
||||
// absolute renders a time for a tooltip, where the reader wants the real value
|
||||
// rather than a distance from now.
|
||||
func absolute(t *time.Time) string {
|
||||
if t == nil {
|
||||
return "never"
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339)
|
||||
return t.UTC().Format("2006-01-02 15:04 MST")
|
||||
}
|
||||
|
||||
func expiresRelative(t *time.Time, now time.Time) string {
|
||||
if t == nil {
|
||||
return "never"
|
||||
}
|
||||
return relative(*t, now)
|
||||
}
|
||||
|
||||
// relative renders a time as a short distance from now - "3d ago", "in 4h" -
|
||||
// which is what a listing is actually read for. The exact time stays available
|
||||
// in the cell's tooltip.
|
||||
func relative(t, now time.Time) string {
|
||||
d := t.Sub(now)
|
||||
ahead := d > 0
|
||||
if !ahead {
|
||||
d = -d
|
||||
}
|
||||
var magnitude string
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
magnitude = "now"
|
||||
case d < time.Hour:
|
||||
magnitude = fmt.Sprintf("%dm", int(d.Minutes()))
|
||||
case d < 24*time.Hour:
|
||||
magnitude = fmt.Sprintf("%dh", int(d.Hours()))
|
||||
case d < 365*24*time.Hour:
|
||||
magnitude = fmt.Sprintf("%dd", int(d.Hours()/24))
|
||||
default:
|
||||
magnitude = fmt.Sprintf("%dy", int(d.Hours()/24/365))
|
||||
}
|
||||
switch {
|
||||
case magnitude == "now":
|
||||
return "now"
|
||||
case ahead:
|
||||
return "in " + magnitude
|
||||
default:
|
||||
return magnitude + " ago"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +196,11 @@ type page struct {
|
||||
// page agrees about who you are without any script involved.
|
||||
User string
|
||||
Admin bool
|
||||
|
||||
// Wide widens the page for content that is a table rather than a form.
|
||||
// The reading measure that suits the upload page is far too narrow for a
|
||||
// listing, which otherwise ends up behind a horizontal scrollbar.
|
||||
Wide bool
|
||||
}
|
||||
|
||||
// page builds the common fields, resolving the session so the header can show
|
||||
@@ -261,3 +266,10 @@ func (s *Server) fail(w http.ResponseWriter, r *http.Request, status int, msg st
|
||||
func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) {
|
||||
s.fail(w, r, http.StatusNotFound, "No such page.")
|
||||
}
|
||||
|
||||
// widePage is page for content that is a listing rather than a form.
|
||||
func (s *Server) widePage(r *http.Request, title string) page {
|
||||
p := s.page(r, title, true)
|
||||
p.Wide = true
|
||||
return p
|
||||
}
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -1791,3 +1793,95 @@ func TestRotationEndsLiveSessions(t *testing.T) {
|
||||
t.Errorf("id = %q: the rotated token lost its vanity permission", res.ID)
|
||||
}
|
||||
}
|
||||
|
||||
// Sorting has to compare the underlying values, not their rendered form: two
|
||||
// uploads in the same minute render identically but are not equal.
|
||||
func TestAdminSortOrdersByValue(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
|
||||
// Three files, distinct in every sortable dimension.
|
||||
type spec struct {
|
||||
name string
|
||||
size int
|
||||
expiry string
|
||||
}
|
||||
for i, s := range []spec{
|
||||
{"big", 300, "3h"},
|
||||
{"small", 10, "1h"},
|
||||
{"medium", 100, "2h"},
|
||||
} {
|
||||
h.now = clock.Add(time.Duration(i) * time.Second) // same minute, distinct instants
|
||||
resp := h.upload(t, bytes.Repeat([]byte("x"), s.size), map[string]string{
|
||||
"Authorization": "Bearer " + h.token,
|
||||
"Vanity": s.name,
|
||||
"Expiry": s.expiry,
|
||||
"Content-Disposition": `attachment; filename="` + s.name + `.bin"`,
|
||||
})
|
||||
resp.Body.Close()
|
||||
}
|
||||
h.now = clock
|
||||
|
||||
order := func(sortBy string) []string {
|
||||
resp := h.get(t, "/admin?sort="+sortBy, h.admin)
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
var ids []string
|
||||
for _, m := range regexp.MustCompile(`class="id mono">([a-z]+)`).FindAllStringSubmatch(string(raw), -1) {
|
||||
ids = append(ids, m[1])
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
for _, c := range []struct {
|
||||
sortBy string
|
||||
want []string
|
||||
}{
|
||||
{"size", []string{"big", "medium", "small"}}, // largest first
|
||||
{"expires", []string{"small", "medium", "big"}}, // soonest first
|
||||
{"created", []string{"medium", "small", "big"}}, // newest first
|
||||
{"name", []string{"big", "medium", "small"}}, // by filename
|
||||
} {
|
||||
got := order(c.sortBy)
|
||||
if !slices.Equal(got, c.want) {
|
||||
t.Errorf("sort=%s gave %v, want %v", c.sortBy, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The listing is a table, and a table needs more room than a form. It also has
|
||||
// to stop being a table on a narrow screen rather than grow a scrollbar.
|
||||
func TestAdminPageIsLaidOutForATable(t *testing.T) {
|
||||
h := newHarness(t, nil)
|
||||
h.upload(t, []byte("x"), nil).Body.Close()
|
||||
|
||||
resp := h.get(t, "/admin", h.admin)
|
||||
raw, _ := io.ReadAll(resp.Body)
|
||||
resp.Body.Close()
|
||||
page := string(raw)
|
||||
|
||||
if !strings.Contains(page, `class="wide"`) {
|
||||
t.Error("the listing renders at the narrow reading measure meant for forms")
|
||||
}
|
||||
// Every cell needs its label for the stacked layout, where the header row
|
||||
// is hidden.
|
||||
for _, label := range []string{"Size", "Owner", "Uploaded", "Expires"} {
|
||||
if !strings.Contains(page, `data-label="`+label+`"`) {
|
||||
t.Errorf("cells carry no %q label, so the stacked layout loses its headings", label)
|
||||
}
|
||||
}
|
||||
|
||||
css := h.get(t, "/static/style.css", "")
|
||||
cssRaw, _ := io.ReadAll(css.Body)
|
||||
css.Body.Close()
|
||||
style := string(cssRaw)
|
||||
|
||||
if !strings.Contains(style, "body.wide") {
|
||||
t.Error("no wide layout is defined")
|
||||
}
|
||||
if !strings.Contains(style, "@media (max-width: 46rem)") {
|
||||
t.Error("no narrow-screen rule, so the table will scroll sideways on a phone")
|
||||
}
|
||||
if strings.Contains(style, "overflow-x: auto") && strings.Contains(page, "tablewrap") {
|
||||
t.Error("the listing still relies on a horizontal scroll container")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user