Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ffa2d9636b |
@@ -17,6 +17,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"warpbox.dev/backend/libs/helpers"
|
||||||
"warpbox.dev/backend/libs/jobs"
|
"warpbox.dev/backend/libs/jobs"
|
||||||
"warpbox.dev/backend/libs/services"
|
"warpbox.dev/backend/libs/services"
|
||||||
"warpbox.dev/backend/libs/web"
|
"warpbox.dev/backend/libs/web"
|
||||||
@@ -36,6 +37,7 @@ type adminPageData struct {
|
|||||||
StorageTypes []adminStorageProviderView
|
StorageTypes []adminStorageProviderView
|
||||||
Logs adminLogsView
|
Logs adminLogsView
|
||||||
Bans adminBansView
|
Bans adminBansView
|
||||||
|
Overview adminOverview
|
||||||
Section string
|
Section string
|
||||||
PageTitle string
|
PageTitle string
|
||||||
LastInviteURL string
|
LastInviteURL string
|
||||||
@@ -52,8 +54,24 @@ type adminLogsView struct {
|
|||||||
Query string
|
Query string
|
||||||
Sort string
|
Sort string
|
||||||
TotalShown int
|
TotalShown int
|
||||||
|
Total int
|
||||||
|
Page int
|
||||||
|
PerPage int
|
||||||
|
PerPageOptions []int
|
||||||
|
TotalPages int
|
||||||
|
RangeFrom int
|
||||||
|
RangeTo int
|
||||||
|
PageLinks []adminFilesPageLink
|
||||||
|
HasPrev bool
|
||||||
|
HasNext bool
|
||||||
|
PrevHref string
|
||||||
|
NextHref string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var adminLogsPageSizes = []int{50, 100, 250, 500}
|
||||||
|
|
||||||
|
const adminLogsDefaultPageSize = 100
|
||||||
|
|
||||||
type adminLogEntry struct {
|
type adminLogEntry struct {
|
||||||
Date string
|
Date string
|
||||||
Time string
|
Time string
|
||||||
@@ -134,6 +152,24 @@ type adminStorageProviderView struct {
|
|||||||
Icon string
|
Icon string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type adminOverview struct {
|
||||||
|
UploadDays []adminChartBar
|
||||||
|
StorageDays []adminChartBar
|
||||||
|
StatusBars []adminStatBar
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminChartBar struct {
|
||||||
|
Label string
|
||||||
|
Value string
|
||||||
|
Height int // 0-100, percent of the tallest bar
|
||||||
|
}
|
||||||
|
|
||||||
|
type adminStatBar struct {
|
||||||
|
Label string
|
||||||
|
Value string
|
||||||
|
Percent int
|
||||||
|
}
|
||||||
|
|
||||||
type adminBoxView struct {
|
type adminBoxView struct {
|
||||||
ID string
|
ID string
|
||||||
Owner string
|
Owner string
|
||||||
@@ -248,25 +284,142 @@ func (a *App) AdminDashboard(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, "unable to load admin stats", http.StatusInternalServerError)
|
http.Error(w, "unable to load admin stats", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
boxes, err := a.adminBoxes(8)
|
allBoxes, err := a.uploadService.AdminBoxes(0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(w, "unable to load recent boxes", http.StatusInternalServerError)
|
http.Error(w, "unable to load boxes", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
overview := buildAdminOverview(allBoxes, stats)
|
||||||
|
recent := a.recentBoxViews(allBoxes, 8)
|
||||||
|
|
||||||
a.renderPage(w, r, http.StatusOK, "admin.html", web.PageData{
|
a.renderPage(w, r, http.StatusOK, "admin.html", web.PageData{
|
||||||
Title: "Admin overview",
|
Title: "Admin overview",
|
||||||
Description: "Warpbox admin overview.",
|
Description: "Warpbox admin overview.",
|
||||||
CurrentUser: a.currentPublicUser(r),
|
CurrentUser: a.currentPublicUser(r),
|
||||||
Data: adminPageData{
|
Data: adminPageData{
|
||||||
Stats: stats,
|
Stats: stats,
|
||||||
Boxes: boxes,
|
Boxes: recent,
|
||||||
|
Overview: overview,
|
||||||
Section: "overview",
|
Section: "overview",
|
||||||
PageTitle: "Admin overview",
|
PageTitle: "Admin overview",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// recentBoxViews renders the newest boxes (already sorted newest-first by the
|
||||||
|
// service) into display rows, resolving owner labels.
|
||||||
|
func (a *App) recentBoxViews(boxes []services.AdminBox, limit int) []adminBoxView {
|
||||||
|
if limit > 0 && len(boxes) > limit {
|
||||||
|
boxes = boxes[:limit]
|
||||||
|
}
|
||||||
|
cache := map[string]string{}
|
||||||
|
rows := make([]adminBoxView, 0, len(boxes))
|
||||||
|
for _, box := range boxes {
|
||||||
|
rows = append(rows, adminBoxView{
|
||||||
|
ID: box.ID,
|
||||||
|
Owner: a.boxOwnerLabel(box.OwnerID, cache),
|
||||||
|
CreatedAt: box.CreatedAt.Format("Jan 2, 2006 15:04"),
|
||||||
|
ExpiresAt: boxExpiryLabel(box.ExpiresAt, "Jan 2, 2006 15:04"),
|
||||||
|
FileCount: box.FileCount,
|
||||||
|
TotalSizeLabel: box.TotalSizeLabel,
|
||||||
|
DownloadCount: box.DownloadCount,
|
||||||
|
MaxDownloads: box.MaxDownloads,
|
||||||
|
Protected: box.Protected,
|
||||||
|
Expired: box.Expired,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAdminOverview computes the last-14-day upload/storage series plus a few
|
||||||
|
// status distributions for the overview dashboard.
|
||||||
|
func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) adminOverview {
|
||||||
|
const days = 14
|
||||||
|
now := time.Now().UTC()
|
||||||
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
counts := make([]int, days)
|
||||||
|
bytes := make([]int64, days)
|
||||||
|
labels := make([]string, days)
|
||||||
|
for i := 0; i < days; i++ {
|
||||||
|
day := today.AddDate(0, 0, -(days - 1 - i))
|
||||||
|
labels[i] = day.Format("Jan 2")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, box := range boxes {
|
||||||
|
created := box.CreatedAt.UTC()
|
||||||
|
day := time.Date(created.Year(), created.Month(), created.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
offset := int(today.Sub(day).Hours() / 24)
|
||||||
|
idx := days - 1 - offset
|
||||||
|
if idx < 0 || idx >= days {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
counts[idx]++
|
||||||
|
bytes[idx] += box.TotalSize
|
||||||
|
}
|
||||||
|
|
||||||
|
maxCount := 0
|
||||||
|
var maxBytes int64
|
||||||
|
for i := 0; i < days; i++ {
|
||||||
|
if counts[i] > maxCount {
|
||||||
|
maxCount = counts[i]
|
||||||
|
}
|
||||||
|
if bytes[i] > maxBytes {
|
||||||
|
maxBytes = bytes[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uploadDays := make([]adminChartBar, days)
|
||||||
|
storageDays := make([]adminChartBar, days)
|
||||||
|
for i := 0; i < days; i++ {
|
||||||
|
uploadDays[i] = adminChartBar{
|
||||||
|
Label: labels[i],
|
||||||
|
Value: strconv.Itoa(counts[i]),
|
||||||
|
Height: scaleHeight(int64(counts[i]), int64(maxCount)),
|
||||||
|
}
|
||||||
|
storageDays[i] = adminChartBar{
|
||||||
|
Label: labels[i],
|
||||||
|
Value: helpers.FormatBytes(bytes[i]),
|
||||||
|
Height: scaleHeight(bytes[i], maxBytes),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
activeBoxes := stats.TotalBoxes - stats.ExpiredBoxes
|
||||||
|
if activeBoxes < 0 {
|
||||||
|
activeBoxes = 0
|
||||||
|
}
|
||||||
|
statusBars := []adminStatBar{
|
||||||
|
{Label: "Active", Value: strconv.Itoa(activeBoxes), Percent: percentOf(activeBoxes, stats.TotalBoxes)},
|
||||||
|
{Label: "Expired", Value: strconv.Itoa(stats.ExpiredBoxes), Percent: percentOf(stats.ExpiredBoxes, stats.TotalBoxes)},
|
||||||
|
{Label: "Password-protected", Value: strconv.Itoa(stats.ProtectedBoxes), Percent: percentOf(stats.ProtectedBoxes, stats.TotalBoxes)},
|
||||||
|
}
|
||||||
|
|
||||||
|
return adminOverview{
|
||||||
|
UploadDays: uploadDays,
|
||||||
|
StorageDays: storageDays,
|
||||||
|
StatusBars: statusBars,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func scaleHeight(value, max int64) int {
|
||||||
|
if max <= 0 || value <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
height := int(value * 100 / max)
|
||||||
|
if height < 4 {
|
||||||
|
height = 4
|
||||||
|
}
|
||||||
|
return height
|
||||||
|
}
|
||||||
|
|
||||||
|
func percentOf(value, total int) int {
|
||||||
|
if total <= 0 || value <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return value * 100 / total
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) AdminUsers(w http.ResponseWriter, r *http.Request) {
|
func (a *App) AdminUsers(w http.ResponseWriter, r *http.Request) {
|
||||||
if !a.requireAdmin(w, r) {
|
if !a.requireAdmin(w, r) {
|
||||||
return
|
return
|
||||||
@@ -1174,38 +1327,6 @@ func (a *App) renderAdminLogin(w http.ResponseWriter, r *http.Request, status in
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *App) adminBoxes(limit int) ([]adminBoxView, error) {
|
|
||||||
boxes, err := a.uploadService.AdminBoxes(limit)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
rows := make([]adminBoxView, 0, len(boxes))
|
|
||||||
for _, box := range boxes {
|
|
||||||
owner := "Anonymous"
|
|
||||||
if box.OwnerID != "" {
|
|
||||||
if user, err := a.authService.UserByID(box.OwnerID); err == nil {
|
|
||||||
owner = user.Email
|
|
||||||
} else {
|
|
||||||
owner = "User"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
rows = append(rows, adminBoxView{
|
|
||||||
ID: box.ID,
|
|
||||||
Owner: owner,
|
|
||||||
CreatedAt: box.CreatedAt.Format("Jan 2 15:04"),
|
|
||||||
ExpiresAt: boxExpiryLabel(box.ExpiresAt, "Jan 2 15:04"),
|
|
||||||
FileCount: box.FileCount,
|
|
||||||
TotalSizeLabel: box.TotalSizeLabel,
|
|
||||||
DownloadCount: box.DownloadCount,
|
|
||||||
MaxDownloads: box.MaxDownloads,
|
|
||||||
Protected: box.Protected,
|
|
||||||
Expired: box.Expired,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return rows, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (a *App) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
|
func (a *App) requireAdmin(w http.ResponseWriter, r *http.Request) bool {
|
||||||
if a.isAdmin(r) {
|
if a.isAdmin(r) {
|
||||||
return true
|
return true
|
||||||
@@ -1465,21 +1586,111 @@ func (a *App) adminLogsView(r *http.Request) (adminLogsView, error) {
|
|||||||
}
|
}
|
||||||
return left > right
|
return left > right
|
||||||
})
|
})
|
||||||
if len(entries) > 500 {
|
|
||||||
entries = entries[:500]
|
perPage := normalizePageSize(r.URL.Query().Get("per"), adminLogsDefaultPageSize, adminLogsPageSizes)
|
||||||
|
total := len(entries)
|
||||||
|
totalPages := (total + perPage - 1) / perPage
|
||||||
|
if totalPages < 1 {
|
||||||
|
totalPages = 1
|
||||||
}
|
}
|
||||||
|
page := 1
|
||||||
|
if parsed, err := strconv.Atoi(r.URL.Query().Get("page")); err == nil && parsed > 1 {
|
||||||
|
page = parsed
|
||||||
|
}
|
||||||
|
if page > totalPages {
|
||||||
|
page = totalPages
|
||||||
|
}
|
||||||
|
start := (page - 1) * perPage
|
||||||
|
if start > total {
|
||||||
|
start = total
|
||||||
|
}
|
||||||
|
end := start + perPage
|
||||||
|
if end > total {
|
||||||
|
end = total
|
||||||
|
}
|
||||||
|
rangeFrom := 0
|
||||||
|
if total > 0 {
|
||||||
|
rangeFrom = start + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
state := adminLogsQuery{
|
||||||
|
Date: selectedDate,
|
||||||
|
Severity: severity,
|
||||||
|
Source: source,
|
||||||
|
Query: r.URL.Query().Get("q"),
|
||||||
|
Sort: sortOrder,
|
||||||
|
Per: perPage,
|
||||||
|
}
|
||||||
|
links := make([]adminFilesPageLink, 0, 5)
|
||||||
|
for p := page - 2; p <= page+2; p++ {
|
||||||
|
if p < 1 || p > totalPages {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
links = append(links, adminFilesPageLink{Page: p, Href: adminLogsHref(state, p), Active: p == page})
|
||||||
|
}
|
||||||
|
|
||||||
return adminLogsView{
|
return adminLogsView{
|
||||||
Entries: entries,
|
Entries: entries[start:end],
|
||||||
Dates: dates,
|
Dates: dates,
|
||||||
Date: selectedDate,
|
Date: selectedDate,
|
||||||
Severity: severity,
|
Severity: severity,
|
||||||
Source: source,
|
Source: source,
|
||||||
Query: r.URL.Query().Get("q"),
|
Query: r.URL.Query().Get("q"),
|
||||||
Sort: sortOrder,
|
Sort: sortOrder,
|
||||||
TotalShown: len(entries),
|
TotalShown: end - start,
|
||||||
|
Total: total,
|
||||||
|
Page: page,
|
||||||
|
PerPage: perPage,
|
||||||
|
PerPageOptions: adminLogsPageSizes,
|
||||||
|
TotalPages: totalPages,
|
||||||
|
RangeFrom: rangeFrom,
|
||||||
|
RangeTo: end,
|
||||||
|
PageLinks: links,
|
||||||
|
HasPrev: page > 1,
|
||||||
|
HasNext: page < totalPages,
|
||||||
|
PrevHref: adminLogsHref(state, page-1),
|
||||||
|
NextHref: adminLogsHref(state, page+1),
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type adminLogsQuery struct {
|
||||||
|
Date string
|
||||||
|
Severity string
|
||||||
|
Source string
|
||||||
|
Query string
|
||||||
|
Sort string
|
||||||
|
Per int
|
||||||
|
}
|
||||||
|
|
||||||
|
func adminLogsHref(state adminLogsQuery, page int) string {
|
||||||
|
values := url.Values{}
|
||||||
|
if state.Date != "" {
|
||||||
|
values.Set("date", state.Date)
|
||||||
|
}
|
||||||
|
if state.Severity != "" {
|
||||||
|
values.Set("severity", state.Severity)
|
||||||
|
}
|
||||||
|
if state.Source != "" {
|
||||||
|
values.Set("source", state.Source)
|
||||||
|
}
|
||||||
|
if state.Query != "" {
|
||||||
|
values.Set("q", state.Query)
|
||||||
|
}
|
||||||
|
if state.Sort != "" && state.Sort != "desc" {
|
||||||
|
values.Set("sort", state.Sort)
|
||||||
|
}
|
||||||
|
if state.Per > 0 && state.Per != adminLogsDefaultPageSize {
|
||||||
|
values.Set("per", strconv.Itoa(state.Per))
|
||||||
|
}
|
||||||
|
if page > 1 {
|
||||||
|
values.Set("page", strconv.Itoa(page))
|
||||||
|
}
|
||||||
|
if len(values) == 0 {
|
||||||
|
return "/admin/logs"
|
||||||
|
}
|
||||||
|
return "/admin/logs?" + values.Encode()
|
||||||
|
}
|
||||||
|
|
||||||
func availableLogDates(logDir string) ([]string, error) {
|
func availableLogDates(logDir string) ([]string, error) {
|
||||||
matches, err := filepath.Glob(filepath.Join(logDir, "*.log"))
|
matches, err := filepath.Glob(filepath.Join(logDir, "*.log"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import (
|
|||||||
"warpbox.dev/backend/libs/web"
|
"warpbox.dev/backend/libs/web"
|
||||||
)
|
)
|
||||||
|
|
||||||
const adminFilesPageSize = 25
|
const adminFilesDefaultPageSize = 50
|
||||||
|
|
||||||
|
var adminFilesPageSizes = []int{25, 50, 100, 200}
|
||||||
|
|
||||||
type adminFilesData struct {
|
type adminFilesData struct {
|
||||||
Stats services.AdminStats
|
Stats services.AdminStats
|
||||||
@@ -25,6 +27,8 @@ type adminFilesData struct {
|
|||||||
Sort string
|
Sort string
|
||||||
Dir string
|
Dir string
|
||||||
Page int
|
Page int
|
||||||
|
PerPage int
|
||||||
|
PerPageOptions []int
|
||||||
TotalPages int
|
TotalPages int
|
||||||
Total int
|
Total int
|
||||||
RangeFrom int
|
RangeFrom int
|
||||||
@@ -37,6 +41,15 @@ type adminFilesData struct {
|
|||||||
NextHref string
|
NextHref string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// adminFilesQuery captures the listing state that every paginated link must
|
||||||
|
// preserve.
|
||||||
|
type adminFilesQuery struct {
|
||||||
|
Query string
|
||||||
|
Sort string
|
||||||
|
Dir string
|
||||||
|
Per int
|
||||||
|
}
|
||||||
|
|
||||||
type adminFilesColumn struct {
|
type adminFilesColumn struct {
|
||||||
Label string
|
Label string
|
||||||
Href string
|
Href string
|
||||||
@@ -153,8 +166,11 @@ func (a *App) AdminFiles(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
sortAdminFileRows(rows, sortKey, dir)
|
sortAdminFileRows(rows, sortKey, dir)
|
||||||
|
|
||||||
|
perPage := normalizePageSize(r.URL.Query().Get("per"), adminFilesDefaultPageSize, adminFilesPageSizes)
|
||||||
|
state := adminFilesQuery{Query: query, Sort: sortKey, Dir: dir, Per: perPage}
|
||||||
|
|
||||||
total := len(rows)
|
total := len(rows)
|
||||||
totalPages := (total + adminFilesPageSize - 1) / adminFilesPageSize
|
totalPages := (total + perPage - 1) / perPage
|
||||||
if totalPages < 1 {
|
if totalPages < 1 {
|
||||||
totalPages = 1
|
totalPages = 1
|
||||||
}
|
}
|
||||||
@@ -165,11 +181,11 @@ func (a *App) AdminFiles(w http.ResponseWriter, r *http.Request) {
|
|||||||
if page > totalPages {
|
if page > totalPages {
|
||||||
page = totalPages
|
page = totalPages
|
||||||
}
|
}
|
||||||
start := (page - 1) * adminFilesPageSize
|
start := (page - 1) * perPage
|
||||||
if start > total {
|
if start > total {
|
||||||
start = total
|
start = total
|
||||||
}
|
}
|
||||||
end := start + adminFilesPageSize
|
end := start + perPage
|
||||||
if end > total {
|
if end > total {
|
||||||
end = total
|
end = total
|
||||||
}
|
}
|
||||||
@@ -208,16 +224,18 @@ func (a *App) AdminFiles(w http.ResponseWriter, r *http.Request) {
|
|||||||
Sort: sortKey,
|
Sort: sortKey,
|
||||||
Dir: dir,
|
Dir: dir,
|
||||||
Page: page,
|
Page: page,
|
||||||
|
PerPage: perPage,
|
||||||
|
PerPageOptions: adminFilesPageSizes,
|
||||||
TotalPages: totalPages,
|
TotalPages: totalPages,
|
||||||
Total: total,
|
Total: total,
|
||||||
RangeFrom: rangeFrom,
|
RangeFrom: rangeFrom,
|
||||||
RangeTo: end,
|
RangeTo: end,
|
||||||
Columns: adminFilesColumns(query, sortKey, dir),
|
Columns: adminFilesColumns(state, sortKey, dir),
|
||||||
PageLinks: adminFilesPageLinks(query, sortKey, dir, page, totalPages),
|
PageLinks: adminFilesPageLinks(state, page, totalPages),
|
||||||
HasPrev: page > 1,
|
HasPrev: page > 1,
|
||||||
HasNext: page < totalPages,
|
HasNext: page < totalPages,
|
||||||
PrevHref: adminFilesHref(query, sortKey, dir, page-1),
|
PrevHref: adminFilesHref(state, page-1),
|
||||||
NextHref: adminFilesHref(query, sortKey, dir, page+1),
|
NextHref: adminFilesHref(state, page+1),
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -274,7 +292,7 @@ func sortAdminFileRows(rows []adminFileRow, sortKey, dir string) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func adminFilesColumns(query, sortKey, dir string) []adminFilesColumn {
|
func adminFilesColumns(state adminFilesQuery, sortKey, dir string) []adminFilesColumn {
|
||||||
defs := []struct{ Key, Label string }{
|
defs := []struct{ Key, Label string }{
|
||||||
{"id", "Box"},
|
{"id", "Box"},
|
||||||
{"owner", "Owner"},
|
{"owner", "Owner"},
|
||||||
@@ -291,9 +309,12 @@ func adminFilesColumns(query, sortKey, dir string) []adminFilesColumn {
|
|||||||
if sorted && dir == "asc" {
|
if sorted && dir == "asc" {
|
||||||
nextDir = "desc"
|
nextDir = "desc"
|
||||||
}
|
}
|
||||||
|
colState := state
|
||||||
|
colState.Sort = def.Key
|
||||||
|
colState.Dir = nextDir
|
||||||
columns = append(columns, adminFilesColumn{
|
columns = append(columns, adminFilesColumn{
|
||||||
Label: def.Label,
|
Label: def.Label,
|
||||||
Href: adminFilesHref(query, def.Key, nextDir, 1),
|
Href: adminFilesHref(colState, 1),
|
||||||
Sorted: sorted,
|
Sorted: sorted,
|
||||||
Ascending: dir == "asc",
|
Ascending: dir == "asc",
|
||||||
})
|
})
|
||||||
@@ -301,7 +322,7 @@ func adminFilesColumns(query, sortKey, dir string) []adminFilesColumn {
|
|||||||
return columns
|
return columns
|
||||||
}
|
}
|
||||||
|
|
||||||
func adminFilesPageLinks(query, sortKey, dir string, page, totalPages int) []adminFilesPageLink {
|
func adminFilesPageLinks(state adminFilesQuery, page, totalPages int) []adminFilesPageLink {
|
||||||
links := make([]adminFilesPageLink, 0, 5)
|
links := make([]adminFilesPageLink, 0, 5)
|
||||||
const window = 2
|
const window = 2
|
||||||
for p := page - window; p <= page+window; p++ {
|
for p := page - window; p <= page+window; p++ {
|
||||||
@@ -310,23 +331,26 @@ func adminFilesPageLinks(query, sortKey, dir string, page, totalPages int) []adm
|
|||||||
}
|
}
|
||||||
links = append(links, adminFilesPageLink{
|
links = append(links, adminFilesPageLink{
|
||||||
Page: p,
|
Page: p,
|
||||||
Href: adminFilesHref(query, sortKey, dir, p),
|
Href: adminFilesHref(state, p),
|
||||||
Active: p == page,
|
Active: p == page,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return links
|
return links
|
||||||
}
|
}
|
||||||
|
|
||||||
func adminFilesHref(query, sortKey, dir string, page int) string {
|
func adminFilesHref(state adminFilesQuery, page int) string {
|
||||||
values := url.Values{}
|
values := url.Values{}
|
||||||
if query != "" {
|
if state.Query != "" {
|
||||||
values.Set("q", query)
|
values.Set("q", state.Query)
|
||||||
}
|
}
|
||||||
if sortKey != "" && sortKey != "created" {
|
if state.Sort != "" && state.Sort != "created" {
|
||||||
values.Set("sort", sortKey)
|
values.Set("sort", state.Sort)
|
||||||
}
|
}
|
||||||
if dir != "" && dir != "desc" {
|
if state.Dir != "" && state.Dir != "desc" {
|
||||||
values.Set("dir", dir)
|
values.Set("dir", state.Dir)
|
||||||
|
}
|
||||||
|
if state.Per > 0 && state.Per != adminFilesDefaultPageSize {
|
||||||
|
values.Set("per", strconv.Itoa(state.Per))
|
||||||
}
|
}
|
||||||
if page > 1 {
|
if page > 1 {
|
||||||
values.Set("page", strconv.Itoa(page))
|
values.Set("page", strconv.Itoa(page))
|
||||||
@@ -337,6 +361,21 @@ func adminFilesHref(query, sortKey, dir string, page int) string {
|
|||||||
return "/admin/files?" + values.Encode()
|
return "/admin/files?" + values.Encode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// normalizePageSize parses a requested page size, falling back to def when the
|
||||||
|
// value is missing or not one of the allowed sizes.
|
||||||
|
func normalizePageSize(raw string, def int, allowed []int) int {
|
||||||
|
parsed, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||||
|
if err != nil {
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
for _, size := range allowed {
|
||||||
|
if size == parsed {
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) AdminEditBox(w http.ResponseWriter, r *http.Request) {
|
func (a *App) AdminEditBox(w http.ResponseWriter, r *http.Request) {
|
||||||
if !a.requireAdmin(w, r) {
|
if !a.requireAdmin(w, r) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -62,7 +62,8 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-edit-metrics {
|
.user-edit-metrics,
|
||||||
|
.metric-grid-4 {
|
||||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,6 +139,146 @@
|
|||||||
font-size: 0.78rem;
|
font-size: 0.78rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pagination-bar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination-bar .pagination {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.per-page-control {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.per-page-control select {
|
||||||
|
width: auto;
|
||||||
|
min-width: 4.5rem;
|
||||||
|
min-height: 2rem;
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.button.is-disabled {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Overview charts */
|
||||||
|
.admin-charts {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 2fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.05rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-card .muted-copy {
|
||||||
|
margin: 0.3rem 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-chart {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.4rem;
|
||||||
|
height: 180px;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-chart-col {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.35rem;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-chart-bar {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 2.2rem;
|
||||||
|
min-height: 2px;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
background: linear-gradient(180deg, var(--primary, #8b5cf6), color-mix(in srgb, var(--primary, #8b5cf6) 55%, transparent));
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-chart-value {
|
||||||
|
color: var(--foreground);
|
||||||
|
font-size: 0.72rem;
|
||||||
|
font-weight: 650;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-chart-label {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.66rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-bars {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.9rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-bar span {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 0.78rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-bar span strong {
|
||||||
|
color: var(--foreground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-bar-track {
|
||||||
|
margin-top: 0.35rem;
|
||||||
|
height: 0.55rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--border);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-bar-fill {
|
||||||
|
display: block;
|
||||||
|
height: 100%;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--primary, #8b5cf6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.admin-charts {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 620px) {
|
||||||
|
.metric-grid-4 {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.table-actions {
|
.table-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
|||||||
43
backend/static/js/35-pagination.js
Normal file
43
backend/static/js/35-pagination.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
// Per-page selector: remembers the chosen page size in localStorage and keeps
|
||||||
|
// the URL's `per` query param in sync. CSP-safe (external file, no inline JS).
|
||||||
|
(function () {
|
||||||
|
const select = document.querySelector("[data-per-page]");
|
||||||
|
if (!select) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = "warpbox-perpage-" + select.dataset.perPage;
|
||||||
|
const url = new URL(window.location.href);
|
||||||
|
const current = url.searchParams.get("per");
|
||||||
|
let stored = null;
|
||||||
|
try {
|
||||||
|
stored = window.localStorage.getItem(key);
|
||||||
|
} catch (err) {
|
||||||
|
stored = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// No explicit choice in the URL but a remembered preference exists: apply it.
|
||||||
|
if (!current && stored && stored !== select.value) {
|
||||||
|
const valid = Array.prototype.some.call(select.options, function (opt) {
|
||||||
|
return opt.value === stored;
|
||||||
|
});
|
||||||
|
if (valid) {
|
||||||
|
url.searchParams.set("per", stored);
|
||||||
|
url.searchParams.delete("page");
|
||||||
|
window.location.replace(url.toString());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
select.addEventListener("change", function () {
|
||||||
|
try {
|
||||||
|
window.localStorage.setItem(key, select.value);
|
||||||
|
} catch (err) {
|
||||||
|
/* ignore storage failures (private mode, etc.) */
|
||||||
|
}
|
||||||
|
const next = new URL(window.location.href);
|
||||||
|
next.searchParams.set("per", select.value);
|
||||||
|
next.searchParams.delete("page");
|
||||||
|
window.location.assign(next.toString());
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -33,6 +33,7 @@
|
|||||||
<script defer src="/static/js/10-file-browser.js?version={{.AppVersion}}"></script>
|
<script defer src="/static/js/10-file-browser.js?version={{.AppVersion}}"></script>
|
||||||
<script defer src="/static/js/20-storage-admin.js?version={{.AppVersion}}"></script>
|
<script defer src="/static/js/20-storage-admin.js?version={{.AppVersion}}"></script>
|
||||||
<script defer src="/static/js/30-token-copy.js?version={{.AppVersion}}"></script>
|
<script defer src="/static/js/30-token-copy.js?version={{.AppVersion}}"></script>
|
||||||
|
<script defer src="/static/js/35-pagination.js?version={{.AppVersion}}"></script>
|
||||||
<script defer src="/static/js/40-upload.js?version={{.AppVersion}}"></script>
|
<script defer src="/static/js/40-upload.js?version={{.AppVersion}}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body class="dark">
|
<body class="dark">
|
||||||
|
|||||||
@@ -58,6 +58,55 @@
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="admin-charts">
|
||||||
|
<div class="card chart-card">
|
||||||
|
<div class="card-content">
|
||||||
|
<h2>Uploads per day</h2>
|
||||||
|
<p class="muted-copy">New boxes created over the last 14 days.</p>
|
||||||
|
<div class="bar-chart" role="img" aria-label="Uploads per day for the last 14 days">
|
||||||
|
{{range .Data.Overview.UploadDays}}
|
||||||
|
<div class="bar-chart-col" title="{{.Label}}: {{.Value}}">
|
||||||
|
<span class="bar-chart-value">{{.Value}}</span>
|
||||||
|
<span class="bar-chart-bar" style="height: {{.Height}}%"></span>
|
||||||
|
<span class="bar-chart-label">{{.Label}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card chart-card">
|
||||||
|
<div class="card-content">
|
||||||
|
<h2>Box status</h2>
|
||||||
|
<p class="muted-copy">Share of all {{.Data.Stats.TotalBoxes}} boxes.</p>
|
||||||
|
<div class="stat-bars">
|
||||||
|
{{range .Data.Overview.StatusBars}}
|
||||||
|
<div class="stat-bar">
|
||||||
|
<span>{{.Label}} <strong>{{.Value}}</strong></span>
|
||||||
|
<span class="stat-bar-track"><span class="stat-bar-fill" style="width: {{.Percent}}%"></span></span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card chart-card">
|
||||||
|
<div class="card-content">
|
||||||
|
<h2>Storage added per day</h2>
|
||||||
|
<p class="muted-copy">Bytes uploaded over the last 14 days.</p>
|
||||||
|
<div class="bar-chart" role="img" aria-label="Storage added per day for the last 14 days">
|
||||||
|
{{range .Data.Overview.StorageDays}}
|
||||||
|
<div class="bar-chart-col" title="{{.Label}}: {{.Value}}">
|
||||||
|
<span class="bar-chart-value">{{.Value}}</span>
|
||||||
|
<span class="bar-chart-bar" style="height: {{.Height}}%"></span>
|
||||||
|
<span class="bar-chart-label">{{.Label}}</span>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card admin-table-card">
|
<div class="card admin-table-card">
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<div class="table-header">
|
<div class="table-header">
|
||||||
|
|||||||
@@ -34,7 +34,7 @@
|
|||||||
{{if .Data.Bans.Notice}}<div class="notice">{{.Data.Bans.Notice}}</div>{{end}}
|
{{if .Data.Bans.Notice}}<div class="notice">{{.Data.Bans.Notice}}</div>{{end}}
|
||||||
{{if .Data.Bans.Error}}<div class="notice notice-error">{{.Data.Bans.Error}}</div>{{end}}
|
{{if .Data.Bans.Error}}<div class="notice notice-error">{{.Data.Bans.Error}}</div>{{end}}
|
||||||
|
|
||||||
<div class="metric-grid">
|
<div class="metric-grid metric-grid-4">
|
||||||
<article class="metric-card"><span>Active bans</span><strong>{{.Data.Bans.ActiveCount}}</strong></article>
|
<article class="metric-card"><span>Active bans</span><strong>{{.Data.Bans.ActiveCount}}</strong></article>
|
||||||
<article class="metric-card"><span>Expired</span><strong>{{.Data.Bans.ExpiredCount}}</strong></article>
|
<article class="metric-card"><span>Expired</span><strong>{{.Data.Bans.ExpiredCount}}</strong></article>
|
||||||
<article class="metric-card"><span>Unbanned</span><strong>{{.Data.Bans.UnbannedCount}}</strong></article>
|
<article class="metric-card"><span>Unbanned</span><strong>{{.Data.Bans.UnbannedCount}}</strong></article>
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
<form class="inline-controls" method="get" action="/admin/files">
|
<form class="inline-controls" method="get" action="/admin/files">
|
||||||
<input type="hidden" name="sort" value="{{.Data.Sort}}">
|
<input type="hidden" name="sort" value="{{.Data.Sort}}">
|
||||||
<input type="hidden" name="dir" value="{{.Data.Dir}}">
|
<input type="hidden" name="dir" value="{{.Data.Dir}}">
|
||||||
|
<input type="hidden" name="per" value="{{.Data.PerPage}}">
|
||||||
<label>
|
<label>
|
||||||
<span class="sr-only">Search</span>
|
<span class="sr-only">Search</span>
|
||||||
<input type="search" name="q" value="{{.Data.Query}}" placeholder="Search box id or owner">
|
<input type="search" name="q" value="{{.Data.Query}}" placeholder="Search box id or owner">
|
||||||
@@ -92,13 +93,19 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if gt .Data.TotalPages 1}}
|
<div class="pagination-bar">
|
||||||
<nav class="pagination" aria-label="Pagination">
|
<nav class="pagination" aria-label="Pagination">
|
||||||
{{if .Data.HasPrev}}<a class="button button-outline button-sm" href="{{.Data.PrevHref}}">← Prev</a>{{end}}
|
{{if .Data.HasPrev}}<a class="button button-outline button-sm" href="{{.Data.PrevHref}}">← Prev</a>{{else}}<span class="button button-outline button-sm is-disabled" aria-disabled="true">← Prev</span>{{end}}
|
||||||
{{range .Data.PageLinks}}<a class="button button-sm {{if .Active}}is-active{{else}}button-outline{{end}}" href="{{.Href}}">{{.Page}}</a>{{end}}
|
{{range .Data.PageLinks}}<a class="button button-sm {{if .Active}}is-active{{else}}button-outline{{end}}" href="{{.Href}}">{{.Page}}</a>{{end}}
|
||||||
{{if .Data.HasNext}}<a class="button button-outline button-sm" href="{{.Data.NextHref}}">Next →</a>{{end}}
|
{{if .Data.HasNext}}<a class="button button-outline button-sm" href="{{.Data.NextHref}}">Next →</a>{{else}}<span class="button button-outline button-sm is-disabled" aria-disabled="true">Next →</span>{{end}}
|
||||||
</nav>
|
</nav>
|
||||||
{{end}}
|
<label class="per-page-control">
|
||||||
|
<span>Per page</span>
|
||||||
|
<select data-per-page="files" aria-label="Items per page">
|
||||||
|
{{range .Data.PerPageOptions}}<option value="{{.}}" {{if eq . $.Data.PerPage}}selected{{end}}>{{.}}</option>{{end}}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<p class="pagination-summary">Showing {{.Data.RangeFrom}}–{{.Data.RangeTo}} of {{.Data.Total}} · Page {{.Data.Page}} of {{.Data.TotalPages}}</p>
|
<p class="pagination-summary">Showing {{.Data.RangeFrom}}–{{.Data.RangeTo}} of {{.Data.Total}} · Page {{.Data.Page}} of {{.Data.TotalPages}}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -54,6 +54,7 @@
|
|||||||
<option value="asc" {{if eq .Data.Logs.Sort "asc"}}selected{{end}}>Oldest first</option>
|
<option value="asc" {{if eq .Data.Logs.Sort "asc"}}selected{{end}}>Oldest first</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
|
<input type="hidden" name="per" value="{{.Data.Logs.PerPage}}">
|
||||||
<button class="button button-primary" type="submit">Filter</button>
|
<button class="button button-primary" type="submit">Filter</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
@@ -62,7 +63,7 @@
|
|||||||
<div class="table-header">
|
<div class="table-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Log entries</h2>
|
<h2>Log entries</h2>
|
||||||
<p>Showing up to 500 entries. {{.Data.Logs.TotalShown}} currently visible.</p>
|
<p>{{.Data.Logs.Total}} entries match these filters.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="admin-table-wrap">
|
<div class="admin-table-wrap">
|
||||||
@@ -98,6 +99,21 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="pagination-bar">
|
||||||
|
<nav class="pagination" aria-label="Pagination">
|
||||||
|
{{if .Data.Logs.HasPrev}}<a class="button button-outline button-sm" href="{{.Data.Logs.PrevHref}}">← Prev</a>{{else}}<span class="button button-outline button-sm is-disabled" aria-disabled="true">← Prev</span>{{end}}
|
||||||
|
{{range .Data.Logs.PageLinks}}<a class="button button-sm {{if .Active}}is-active{{else}}button-outline{{end}}" href="{{.Href}}">{{.Page}}</a>{{end}}
|
||||||
|
{{if .Data.Logs.HasNext}}<a class="button button-outline button-sm" href="{{.Data.Logs.NextHref}}">Next →</a>{{else}}<span class="button button-outline button-sm is-disabled" aria-disabled="true">Next →</span>{{end}}
|
||||||
|
</nav>
|
||||||
|
<label class="per-page-control">
|
||||||
|
<span>Per page</span>
|
||||||
|
<select data-per-page="logs" aria-label="Items per page">
|
||||||
|
{{range .Data.Logs.PerPageOptions}}<option value="{{.}}" {{if eq . $.Data.Logs.PerPage}}selected{{end}}>{{.}}</option>{{end}}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<p class="pagination-summary">Showing {{.Data.Logs.RangeFrom}}–{{.Data.Logs.RangeTo}} of {{.Data.Logs.Total}} · Page {{.Data.Logs.Page}} of {{.Data.Logs.TotalPages}}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user