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