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 {
|
||||
|
||||
Reference in New Issue
Block a user