feat(admin): add dashboard overview charts and log pagination
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m40s
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m40s
Enhance the admin panel by introducing visual overview charts for upload and storage trends, along with status bars for system metrics. Additionally, implement pagination for the admin logs view, allowing users to navigate through log entries with configurable page sizes. Corresponding CSS styles have been added for the new charts, metrics grid, and pagination controls.
This commit is contained in:
@@ -17,6 +17,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"warpbox.dev/backend/libs/helpers"
|
||||
"warpbox.dev/backend/libs/jobs"
|
||||
"warpbox.dev/backend/libs/services"
|
||||
"warpbox.dev/backend/libs/web"
|
||||
@@ -36,6 +37,7 @@ type adminPageData struct {
|
||||
StorageTypes []adminStorageProviderView
|
||||
Logs adminLogsView
|
||||
Bans adminBansView
|
||||
Overview adminOverview
|
||||
Section string
|
||||
PageTitle string
|
||||
LastInviteURL string
|
||||
@@ -44,16 +46,32 @@ type adminPageData struct {
|
||||
}
|
||||
|
||||
type adminLogsView struct {
|
||||
Entries []adminLogEntry
|
||||
Dates []string
|
||||
Date string
|
||||
Severity string
|
||||
Source string
|
||||
Query string
|
||||
Sort string
|
||||
TotalShown int
|
||||
Entries []adminLogEntry
|
||||
Dates []string
|
||||
Date string
|
||||
Severity string
|
||||
Source string
|
||||
Query string
|
||||
Sort string
|
||||
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 {
|
||||
Date string
|
||||
Time string
|
||||
@@ -134,6 +152,24 @@ type adminStorageProviderView struct {
|
||||
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 {
|
||||
ID 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)
|
||||
return
|
||||
}
|
||||
boxes, err := a.adminBoxes(8)
|
||||
allBoxes, err := a.uploadService.AdminBoxes(0)
|
||||
if err != nil {
|
||||
http.Error(w, "unable to load recent boxes", http.StatusInternalServerError)
|
||||
http.Error(w, "unable to load boxes", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
overview := buildAdminOverview(allBoxes, stats)
|
||||
recent := a.recentBoxViews(allBoxes, 8)
|
||||
|
||||
a.renderPage(w, r, http.StatusOK, "admin.html", web.PageData{
|
||||
Title: "Admin overview",
|
||||
Description: "Warpbox admin overview.",
|
||||
CurrentUser: a.currentPublicUser(r),
|
||||
Data: adminPageData{
|
||||
Stats: stats,
|
||||
Boxes: boxes,
|
||||
Boxes: recent,
|
||||
Overview: overview,
|
||||
Section: "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) {
|
||||
if !a.requireAdmin(w, r) {
|
||||
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 {
|
||||
if a.isAdmin(r) {
|
||||
return true
|
||||
@@ -1465,21 +1586,111 @@ func (a *App) adminLogsView(r *http.Request) (adminLogsView, error) {
|
||||
}
|
||||
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{
|
||||
Entries: entries,
|
||||
Dates: dates,
|
||||
Date: selectedDate,
|
||||
Severity: severity,
|
||||
Source: source,
|
||||
Query: r.URL.Query().Get("q"),
|
||||
Sort: sortOrder,
|
||||
TotalShown: len(entries),
|
||||
Entries: entries[start:end],
|
||||
Dates: dates,
|
||||
Date: selectedDate,
|
||||
Severity: severity,
|
||||
Source: source,
|
||||
Query: r.URL.Query().Get("q"),
|
||||
Sort: sortOrder,
|
||||
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
|
||||
}
|
||||
|
||||
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) {
|
||||
matches, err := filepath.Glob(filepath.Join(logDir, "*.log"))
|
||||
if err != nil {
|
||||
|
||||
@@ -14,27 +14,40 @@ import (
|
||||
"warpbox.dev/backend/libs/web"
|
||||
)
|
||||
|
||||
const adminFilesPageSize = 25
|
||||
const adminFilesDefaultPageSize = 50
|
||||
|
||||
var adminFilesPageSizes = []int{25, 50, 100, 200}
|
||||
|
||||
type adminFilesData struct {
|
||||
Stats services.AdminStats
|
||||
Section string
|
||||
PageTitle string
|
||||
Boxes []adminBoxView
|
||||
Query string
|
||||
Sort string
|
||||
Dir string
|
||||
Page int
|
||||
TotalPages int
|
||||
Total int
|
||||
RangeFrom int
|
||||
RangeTo int
|
||||
Columns []adminFilesColumn
|
||||
PageLinks []adminFilesPageLink
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
PrevHref string
|
||||
NextHref string
|
||||
Stats services.AdminStats
|
||||
Section string
|
||||
PageTitle string
|
||||
Boxes []adminBoxView
|
||||
Query string
|
||||
Sort string
|
||||
Dir string
|
||||
Page int
|
||||
PerPage int
|
||||
PerPageOptions []int
|
||||
TotalPages int
|
||||
Total int
|
||||
RangeFrom int
|
||||
RangeTo int
|
||||
Columns []adminFilesColumn
|
||||
PageLinks []adminFilesPageLink
|
||||
HasPrev bool
|
||||
HasNext bool
|
||||
PrevHref 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 {
|
||||
@@ -153,8 +166,11 @@ func (a *App) AdminFiles(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
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)
|
||||
totalPages := (total + adminFilesPageSize - 1) / adminFilesPageSize
|
||||
totalPages := (total + perPage - 1) / perPage
|
||||
if totalPages < 1 {
|
||||
totalPages = 1
|
||||
}
|
||||
@@ -165,11 +181,11 @@ func (a *App) AdminFiles(w http.ResponseWriter, r *http.Request) {
|
||||
if page > totalPages {
|
||||
page = totalPages
|
||||
}
|
||||
start := (page - 1) * adminFilesPageSize
|
||||
start := (page - 1) * perPage
|
||||
if start > total {
|
||||
start = total
|
||||
}
|
||||
end := start + adminFilesPageSize
|
||||
end := start + perPage
|
||||
if end > total {
|
||||
end = total
|
||||
}
|
||||
@@ -200,24 +216,26 @@ func (a *App) AdminFiles(w http.ResponseWriter, r *http.Request) {
|
||||
Description: "Manage Warpbox uploads.",
|
||||
CurrentUser: a.currentPublicUser(r),
|
||||
Data: adminFilesData{
|
||||
Stats: stats,
|
||||
Section: "files",
|
||||
PageTitle: "Files",
|
||||
Boxes: views,
|
||||
Query: query,
|
||||
Sort: sortKey,
|
||||
Dir: dir,
|
||||
Page: page,
|
||||
TotalPages: totalPages,
|
||||
Total: total,
|
||||
RangeFrom: rangeFrom,
|
||||
RangeTo: end,
|
||||
Columns: adminFilesColumns(query, sortKey, dir),
|
||||
PageLinks: adminFilesPageLinks(query, sortKey, dir, page, totalPages),
|
||||
HasPrev: page > 1,
|
||||
HasNext: page < totalPages,
|
||||
PrevHref: adminFilesHref(query, sortKey, dir, page-1),
|
||||
NextHref: adminFilesHref(query, sortKey, dir, page+1),
|
||||
Stats: stats,
|
||||
Section: "files",
|
||||
PageTitle: "Files",
|
||||
Boxes: views,
|
||||
Query: query,
|
||||
Sort: sortKey,
|
||||
Dir: dir,
|
||||
Page: page,
|
||||
PerPage: perPage,
|
||||
PerPageOptions: adminFilesPageSizes,
|
||||
TotalPages: totalPages,
|
||||
Total: total,
|
||||
RangeFrom: rangeFrom,
|
||||
RangeTo: end,
|
||||
Columns: adminFilesColumns(state, sortKey, dir),
|
||||
PageLinks: adminFilesPageLinks(state, page, totalPages),
|
||||
HasPrev: page > 1,
|
||||
HasNext: page < totalPages,
|
||||
PrevHref: adminFilesHref(state, 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 }{
|
||||
{"id", "Box"},
|
||||
{"owner", "Owner"},
|
||||
@@ -291,9 +309,12 @@ func adminFilesColumns(query, sortKey, dir string) []adminFilesColumn {
|
||||
if sorted && dir == "asc" {
|
||||
nextDir = "desc"
|
||||
}
|
||||
colState := state
|
||||
colState.Sort = def.Key
|
||||
colState.Dir = nextDir
|
||||
columns = append(columns, adminFilesColumn{
|
||||
Label: def.Label,
|
||||
Href: adminFilesHref(query, def.Key, nextDir, 1),
|
||||
Href: adminFilesHref(colState, 1),
|
||||
Sorted: sorted,
|
||||
Ascending: dir == "asc",
|
||||
})
|
||||
@@ -301,7 +322,7 @@ func adminFilesColumns(query, sortKey, dir string) []adminFilesColumn {
|
||||
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)
|
||||
const window = 2
|
||||
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{
|
||||
Page: p,
|
||||
Href: adminFilesHref(query, sortKey, dir, p),
|
||||
Href: adminFilesHref(state, p),
|
||||
Active: p == page,
|
||||
})
|
||||
}
|
||||
return links
|
||||
}
|
||||
|
||||
func adminFilesHref(query, sortKey, dir string, page int) string {
|
||||
func adminFilesHref(state adminFilesQuery, page int) string {
|
||||
values := url.Values{}
|
||||
if query != "" {
|
||||
values.Set("q", query)
|
||||
if state.Query != "" {
|
||||
values.Set("q", state.Query)
|
||||
}
|
||||
if sortKey != "" && sortKey != "created" {
|
||||
values.Set("sort", sortKey)
|
||||
if state.Sort != "" && state.Sort != "created" {
|
||||
values.Set("sort", state.Sort)
|
||||
}
|
||||
if dir != "" && dir != "desc" {
|
||||
values.Set("dir", dir)
|
||||
if state.Dir != "" && state.Dir != "desc" {
|
||||
values.Set("dir", state.Dir)
|
||||
}
|
||||
if state.Per > 0 && state.Per != adminFilesDefaultPageSize {
|
||||
values.Set("per", strconv.Itoa(state.Per))
|
||||
}
|
||||
if page > 1 {
|
||||
values.Set("page", strconv.Itoa(page))
|
||||
@@ -337,6 +361,21 @@ func adminFilesHref(query, sortKey, dir string, page int) string {
|
||||
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) {
|
||||
if !a.requireAdmin(w, r) {
|
||||
return
|
||||
|
||||
@@ -62,7 +62,8 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-edit-metrics {
|
||||
.user-edit-metrics,
|
||||
.metric-grid-4 {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@@ -138,6 +139,146 @@
|
||||
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 {
|
||||
display: flex;
|
||||
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/20-storage-admin.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>
|
||||
</head>
|
||||
<body class="dark">
|
||||
|
||||
@@ -58,6 +58,55 @@
|
||||
</article>
|
||||
</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-content">
|
||||
<div class="table-header">
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
{{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}}
|
||||
|
||||
<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>Expired</span><strong>{{.Data.Bans.ExpiredCount}}</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">
|
||||
<input type="hidden" name="sort" value="{{.Data.Sort}}">
|
||||
<input type="hidden" name="dir" value="{{.Data.Dir}}">
|
||||
<input type="hidden" name="per" value="{{.Data.PerPage}}">
|
||||
<label>
|
||||
<span class="sr-only">Search</span>
|
||||
<input type="search" name="q" value="{{.Data.Query}}" placeholder="Search box id or owner">
|
||||
@@ -92,13 +93,19 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{{if gt .Data.TotalPages 1}}
|
||||
<div class="pagination-bar">
|
||||
<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}}
|
||||
{{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>
|
||||
{{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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
<option value="asc" {{if eq .Data.Logs.Sort "asc"}}selected{{end}}>Oldest first</option>
|
||||
</select>
|
||||
</label>
|
||||
<input type="hidden" name="per" value="{{.Data.Logs.PerPage}}">
|
||||
<button class="button button-primary" type="submit">Filter</button>
|
||||
</form>
|
||||
|
||||
@@ -62,7 +63,7 @@
|
||||
<div class="table-header">
|
||||
<div>
|
||||
<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 class="admin-table-wrap">
|
||||
@@ -98,6 +99,21 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</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>
|
||||
|
||||
Reference in New Issue
Block a user