From ffa2d9636b954e16d53868ce8625c85406c8b4dd Mon Sep 17 00:00:00 2001 From: Daniel Legt Date: Mon, 1 Jun 2026 04:22:38 +0300 Subject: [PATCH] feat(admin): add dashboard overview charts and log pagination 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. --- backend/libs/handlers/admin.go | 317 +++++++++++++++++++---- backend/libs/handlers/admin_files.go | 141 ++++++---- backend/static/css/50-admin.css | 143 +++++++++- backend/static/js/35-pagination.js | 43 +++ backend/templates/layouts/base.html | 1 + backend/templates/pages/admin.html | 49 ++++ backend/templates/pages/admin_bans.html | 2 +- backend/templates/pages/admin_files.html | 15 +- backend/templates/pages/admin_logs.html | 18 +- 9 files changed, 618 insertions(+), 111 deletions(-) create mode 100644 backend/static/js/35-pagination.js diff --git a/backend/libs/handlers/admin.go b/backend/libs/handlers/admin.go index 0ece374..9b25171 100644 --- a/backend/libs/handlers/admin.go +++ b/backend/libs/handlers/admin.go @@ -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 { diff --git a/backend/libs/handlers/admin_files.go b/backend/libs/handlers/admin_files.go index 3f0d0f3..6b9cf89 100644 --- a/backend/libs/handlers/admin_files.go +++ b/backend/libs/handlers/admin_files.go @@ -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 diff --git a/backend/static/css/50-admin.css b/backend/static/css/50-admin.css index 679f609..d71fe1b 100644 --- a/backend/static/css/50-admin.css +++ b/backend/static/css/50-admin.css @@ -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; diff --git a/backend/static/js/35-pagination.js b/backend/static/js/35-pagination.js new file mode 100644 index 0000000..25a5337 --- /dev/null +++ b/backend/static/js/35-pagination.js @@ -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()); + }); +})(); diff --git a/backend/templates/layouts/base.html b/backend/templates/layouts/base.html index 18ae514..9a3bace 100644 --- a/backend/templates/layouts/base.html +++ b/backend/templates/layouts/base.html @@ -33,6 +33,7 @@ + diff --git a/backend/templates/pages/admin.html b/backend/templates/pages/admin.html index ff1b974..7b1a56f 100644 --- a/backend/templates/pages/admin.html +++ b/backend/templates/pages/admin.html @@ -58,6 +58,55 @@ +
+
+
+

Uploads per day

+

New boxes created over the last 14 days.

+ +
+
+ +
+
+

Box status

+

Share of all {{.Data.Stats.TotalBoxes}} boxes.

+
+ {{range .Data.Overview.StatusBars}} +
+ {{.Label}} {{.Value}} + +
+ {{end}} +
+
+
+
+ +
+
+

Storage added per day

+

Bytes uploaded over the last 14 days.

+ +
+
+
diff --git a/backend/templates/pages/admin_bans.html b/backend/templates/pages/admin_bans.html index f8947f7..4d1d250 100644 --- a/backend/templates/pages/admin_bans.html +++ b/backend/templates/pages/admin_bans.html @@ -34,7 +34,7 @@ {{if .Data.Bans.Notice}}
{{.Data.Bans.Notice}}
{{end}} {{if .Data.Bans.Error}}
{{.Data.Bans.Error}}
{{end}} -
+
Active bans{{.Data.Bans.ActiveCount}}
Expired{{.Data.Bans.ExpiredCount}}
Unbanned{{.Data.Bans.UnbannedCount}}
diff --git a/backend/templates/pages/admin_files.html b/backend/templates/pages/admin_files.html index d79019c..79f337e 100644 --- a/backend/templates/pages/admin_files.html +++ b/backend/templates/pages/admin_files.html @@ -42,6 +42,7 @@
+
- {{if gt .Data.TotalPages 1}} +
- {{end}} + +

Showing {{.Data.RangeFrom}}–{{.Data.RangeTo}} of {{.Data.Total}} · Page {{.Data.Page}} of {{.Data.TotalPages}}

diff --git a/backend/templates/pages/admin_logs.html b/backend/templates/pages/admin_logs.html index 6813eea..24488dc 100644 --- a/backend/templates/pages/admin_logs.html +++ b/backend/templates/pages/admin_logs.html @@ -54,6 +54,7 @@ + @@ -62,7 +63,7 @@

Log entries

-

Showing up to 500 entries. {{.Data.Logs.TotalShown}} currently visible.

+

{{.Data.Logs.Total}} entries match these filters.

@@ -98,6 +99,21 @@
+ +
+ + +
+

Showing {{.Data.Logs.RangeFrom}}–{{.Data.Logs.RangeTo}} of {{.Data.Logs.Total}} · Page {{.Data.Logs.Page}} of {{.Data.Logs.TotalPages}}