feat(admin): add dashboard overview charts and log pagination
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:
2026-06-01 04:22:38 +03:00
parent cc91ce120d
commit ffa2d9636b
9 changed files with 618 additions and 111 deletions

View File

@@ -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
@@ -44,16 +46,32 @@ type adminPageData struct {
} }
type adminLogsView struct { type adminLogsView struct {
Entries []adminLogEntry Entries []adminLogEntry
Dates []string Dates []string
Date string Date string
Severity string Severity string
Source string Source string
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 {

View File

@@ -14,27 +14,40 @@ 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
Section string Section string
PageTitle string PageTitle string
Boxes []adminBoxView Boxes []adminBoxView
Query string Query string
Sort string Sort string
Dir string Dir string
Page int Page int
TotalPages int PerPage int
Total int PerPageOptions []int
RangeFrom int TotalPages int
RangeTo int Total int
Columns []adminFilesColumn RangeFrom int
PageLinks []adminFilesPageLink RangeTo int
HasPrev bool Columns []adminFilesColumn
HasNext bool PageLinks []adminFilesPageLink
PrevHref string HasPrev bool
NextHref string 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 { type adminFilesColumn struct {
@@ -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
} }
@@ -200,24 +216,26 @@ func (a *App) AdminFiles(w http.ResponseWriter, r *http.Request) {
Description: "Manage Warpbox uploads.", Description: "Manage Warpbox uploads.",
CurrentUser: a.currentPublicUser(r), CurrentUser: a.currentPublicUser(r),
Data: adminFilesData{ Data: adminFilesData{
Stats: stats, Stats: stats,
Section: "files", Section: "files",
PageTitle: "Files", PageTitle: "Files",
Boxes: views, Boxes: views,
Query: query, Query: query,
Sort: sortKey, Sort: sortKey,
Dir: dir, Dir: dir,
Page: page, Page: page,
TotalPages: totalPages, PerPage: perPage,
Total: total, PerPageOptions: adminFilesPageSizes,
RangeFrom: rangeFrom, TotalPages: totalPages,
RangeTo: end, Total: total,
Columns: adminFilesColumns(query, sortKey, dir), RangeFrom: rangeFrom,
PageLinks: adminFilesPageLinks(query, sortKey, dir, page, totalPages), RangeTo: end,
HasPrev: page > 1, Columns: adminFilesColumns(state, sortKey, dir),
HasNext: page < totalPages, PageLinks: adminFilesPageLinks(state, page, totalPages),
PrevHref: adminFilesHref(query, sortKey, dir, page-1), HasPrev: page > 1,
NextHref: adminFilesHref(query, sortKey, dir, 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 }{ 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

View File

@@ -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;

View 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());
});
})();

View File

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

View File

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

View File

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

View File

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

View File

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