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 @@ +New boxes created over the last 14 days.
+ +Share of all {{.Data.Stats.TotalBoxes}} boxes.
+ +Bytes uploaded over the last 14 days.
+ +Showing {{.Data.RangeFrom}}–{{.Data.RangeTo}} of {{.Data.Total}} · Page {{.Data.Page}} of {{.Data.TotalPages}}
Showing up to 500 entries. {{.Data.Logs.TotalShown}} currently visible.
+{{.Data.Logs.Total}} entries match these filters.
Showing {{.Data.Logs.RangeFrom}}–{{.Data.Logs.RangeTo}} of {{.Data.Logs.Total}} · Page {{.Data.Logs.Page}} of {{.Data.Logs.TotalPages}}