refactor(admin): use inline pixel heights for overview charts
All checks were successful
Build and Publish Docker Image / deploy (push) Successful in 1m40s

Refactors the admin overview dashboard charts to use inline pixel heights (up to 150px) instead of CSS variables and percentage-based heights. This provides more robust rendering and layout control.

Changes include:
- Replacing `Height` with `HeightPx` in chart bar structures.
- Rendering inline styles for height and width on charts and status bars.
- Adding fallback data attributes (`data-height-px`, `data-chart-value`, etc.) and loading a new fallback script (`25-admin-charts.js`).
- Updating and expanding test coverage to assert correct scaling and HTML rendering.
This commit is contained in:
2026-06-01 12:30:59 +03:00
parent 38afc6c34d
commit c9f865cd85
6 changed files with 174 additions and 45 deletions

View File

@@ -707,24 +707,60 @@ func TestAdminOverviewChartsUseZeroAndFullHeights(t *testing.T) {
for i, bar := range overview.UploadDays { for i, bar := range overview.UploadDays {
want := 0 want := 0
if i == len(overview.UploadDays)-1 { if i == len(overview.UploadDays)-1 {
want = 100 want = 150
} }
if bar.Height != want { if bar.HeightPx != want {
t.Fatalf("upload bar %d height = %d, want %d", i, bar.Height, want) t.Fatalf("upload bar %d height = %d, want %d", i, bar.HeightPx, want)
} }
} }
for i, bar := range overview.StorageDays { for i, bar := range overview.StorageDays {
want := 0 want := 0
if i == len(overview.StorageDays)-1 { if i == len(overview.StorageDays)-1 {
want = 100 want = 150
} }
if bar.Height != want { if bar.HeightPx != want {
t.Fatalf("storage bar %d height = %d, want %d", i, bar.Height, want) t.Fatalf("storage bar %d height = %d, want %d", i, bar.HeightPx, want)
} }
} }
if overview.StatusBars[0].WidthPercent != 100 {
t.Fatalf("active status width = %d, want 100", overview.StatusBars[0].WidthPercent)
}
} }
func TestAdminOverviewRendersBarHeightVariables(t *testing.T) { func TestAdminOverviewChartsScaleRelativeToVisibleRange(t *testing.T) {
now := time.Now().UTC()
today := time.Date(now.Year(), now.Month(), now.Day(), 12, 0, 0, 0, time.UTC)
yesterday := today.AddDate(0, 0, -1)
twoDaysAgo := today.AddDate(0, 0, -2)
boxes := []services.AdminBox{
{ID: "today-1", CreatedAt: today, TotalSize: 30},
{ID: "today-2", CreatedAt: today, TotalSize: 30},
{ID: "today-3", CreatedAt: today, TotalSize: 30},
{ID: "yesterday-1", CreatedAt: yesterday, TotalSize: 20},
{ID: "yesterday-2", CreatedAt: yesterday, TotalSize: 20},
{ID: "two-days-ago", CreatedAt: twoDaysAgo, TotalSize: 10},
}
overview := buildAdminOverview(boxes, services.AdminStats{TotalBoxes: 6, ExpiredBoxes: 2, ProtectedBoxes: 1})
last := len(overview.UploadDays) - 1
if overview.UploadDays[last].HeightPx != 150 {
t.Fatalf("3-upload day height = %d, want 150", overview.UploadDays[last].HeightPx)
}
if overview.UploadDays[last-1].HeightPx != 100 {
t.Fatalf("2-upload day height = %d, want 100", overview.UploadDays[last-1].HeightPx)
}
if overview.UploadDays[last-2].HeightPx != 50 {
t.Fatalf("1-upload day height = %d, want 50", overview.UploadDays[last-2].HeightPx)
}
if overview.StorageDays[last].HeightPx != 150 || overview.StorageDays[last-1].HeightPx != 66 || overview.StorageDays[last-2].HeightPx != 16 {
t.Fatalf("storage heights = %d/%d/%d, want 150/66/16", overview.StorageDays[last].HeightPx, overview.StorageDays[last-1].HeightPx, overview.StorageDays[last-2].HeightPx)
}
if overview.StatusBars[0].WidthPercent != 100 || overview.StatusBars[1].WidthPercent != 50 || overview.StatusBars[2].WidthPercent != 25 {
t.Fatalf("status widths = %d/%d/%d, want 100/50/25", overview.StatusBars[0].WidthPercent, overview.StatusBars[1].WidthPercent, overview.StatusBars[2].WidthPercent)
}
}
func TestAdminOverviewRendersInlineBarDimensions(t *testing.T) {
app, cleanup := newTestApp(t) app, cleanup := newTestApp(t)
defer cleanup() defer cleanup()
adminToken := createAdminSession(t, app) adminToken := createAdminSession(t, app)
@@ -738,11 +774,26 @@ func TestAdminOverviewRendersBarHeightVariables(t *testing.T) {
t.Fatalf("AdminDashboard status = %d, body = %s", response.Code, response.Body.String()) t.Fatalf("AdminDashboard status = %d, body = %s", response.Code, response.Body.String())
} }
body := response.Body.String() body := response.Body.String()
if !strings.Contains(body, "--bar-height: 100%") { if !strings.Contains(body, `style="height: 150px"`) {
t.Fatalf("admin overview did not render a full-height bar: %s", body) t.Fatalf("admin overview did not render a full-height pixel bar: %s", body)
} }
if strings.Contains(body, `style="height:`) { if !strings.Contains(body, `data-height-px="150"`) || !strings.Contains(body, `data-chart-value=`) {
t.Fatalf("admin overview still uses fragile percent height styles: %s", body) t.Fatalf("admin overview did not render chart fallback data attributes: %s", body)
}
if !strings.Contains(body, `style="height: 0px"`) {
t.Fatalf("admin overview did not render zero pixel bars: %s", body)
}
if !strings.Contains(body, `style="width: 100%"`) {
t.Fatalf("admin overview did not render a full-width status bar: %s", body)
}
if !strings.Contains(body, `data-width-percent="100"`) || !strings.Contains(body, `data-stat-value=`) {
t.Fatalf("admin overview did not render status fallback data attributes: %s", body)
}
if strings.Contains(body, "--bar-height") {
t.Fatalf("admin overview still uses css variable bar heights: %s", body)
}
if !strings.Contains(body, "/static/js/25-admin-charts.js?version=test") {
t.Fatalf("admin overview did not load chart fallback script: %s", body)
} }
} }

View File

@@ -159,15 +159,17 @@ type adminOverview struct {
} }
type adminChartBar struct { type adminChartBar struct {
Label string Label string
Value string Value string
Height int // 0-100, percent of the tallest bar HeightPx int
RawValue int64
} }
type adminStatBar struct { type adminStatBar struct {
Label string Label string
Value string Value string
Percent int RawValue int
WidthPercent int
} }
type adminBoxView struct { type adminBoxView struct {
@@ -336,6 +338,7 @@ func (a *App) recentBoxViews(boxes []services.AdminBox, limit int) []adminBoxVie
// status distributions for the overview dashboard. // status distributions for the overview dashboard.
func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) adminOverview { func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) adminOverview {
const days = 14 const days = 14
const chartMaxHeightPx = 150
now := time.Now().UTC() now := time.Now().UTC()
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC) today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
@@ -374,14 +377,16 @@ func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) ad
storageDays := make([]adminChartBar, days) storageDays := make([]adminChartBar, days)
for i := 0; i < days; i++ { for i := 0; i < days; i++ {
uploadDays[i] = adminChartBar{ uploadDays[i] = adminChartBar{
Label: labels[i], Label: labels[i],
Value: strconv.Itoa(counts[i]), Value: strconv.Itoa(counts[i]),
Height: scaleHeight(int64(counts[i]), int64(maxCount)), HeightPx: scaleHeightPx(int64(counts[i]), int64(maxCount), chartMaxHeightPx),
RawValue: int64(counts[i]),
} }
storageDays[i] = adminChartBar{ storageDays[i] = adminChartBar{
Label: labels[i], Label: labels[i],
Value: helpers.FormatBytes(bytes[i]), Value: helpers.FormatBytes(bytes[i]),
Height: scaleHeight(bytes[i], maxBytes), HeightPx: scaleHeightPx(bytes[i], maxBytes, chartMaxHeightPx),
RawValue: bytes[i],
} }
} }
@@ -389,10 +394,11 @@ func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) ad
if activeBoxes < 0 { if activeBoxes < 0 {
activeBoxes = 0 activeBoxes = 0
} }
maxStatusValue := maxInt(activeBoxes, stats.ExpiredBoxes, stats.ProtectedBoxes)
statusBars := []adminStatBar{ statusBars := []adminStatBar{
{Label: "Active", Value: strconv.Itoa(activeBoxes), Percent: percentOf(activeBoxes, stats.TotalBoxes)}, {Label: "Active", Value: strconv.Itoa(activeBoxes), RawValue: activeBoxes, WidthPercent: percentOf(activeBoxes, maxStatusValue)},
{Label: "Expired", Value: strconv.Itoa(stats.ExpiredBoxes), Percent: percentOf(stats.ExpiredBoxes, stats.TotalBoxes)}, {Label: "Expired", Value: strconv.Itoa(stats.ExpiredBoxes), RawValue: stats.ExpiredBoxes, WidthPercent: percentOf(stats.ExpiredBoxes, maxStatusValue)},
{Label: "Password-protected", Value: strconv.Itoa(stats.ProtectedBoxes), Percent: percentOf(stats.ProtectedBoxes, stats.TotalBoxes)}, {Label: "Password-protected", Value: strconv.Itoa(stats.ProtectedBoxes), RawValue: stats.ProtectedBoxes, WidthPercent: percentOf(stats.ProtectedBoxes, maxStatusValue)},
} }
return adminOverview{ return adminOverview{
@@ -402,13 +408,16 @@ func buildAdminOverview(boxes []services.AdminBox, stats services.AdminStats) ad
} }
} }
func scaleHeight(value, max int64) int { func scaleHeightPx(value, max int64, maxHeightPx int) int {
if max <= 0 || value <= 0 { if max <= 0 || value <= 0 {
return 0 return 0
} }
height := int(value * 100 / max) height := int(value * int64(maxHeightPx) / max)
if height < 4 { if height < 8 {
height = 4 height = 8
}
if height > maxHeightPx {
return maxHeightPx
} }
return height return height
} }
@@ -420,6 +429,16 @@ func percentOf(value, total int) int {
return value * 100 / total return value * 100 / total
} }
func maxInt(values ...int) int {
max := 0
for _, value := range values {
if value > max {
max = value
}
}
return max
}
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

View File

@@ -198,9 +198,9 @@
.bar-chart { .bar-chart {
display: grid; display: grid;
grid-template-columns: repeat(14, minmax(0, 1fr)); grid-template-columns: repeat(14, minmax(0, 1fr));
align-items: stretch; align-items: end;
gap: 0.4rem; gap: 0.4rem;
min-height: 15rem; min-height: 13rem;
margin-top: 1.25rem; margin-top: 1.25rem;
padding-top: 0.5rem; padding-top: 0.5rem;
} }
@@ -214,11 +214,13 @@
} }
.bar-chart-track { .bar-chart-track {
position: relative; display: flex;
align-items: flex-end;
justify-content: center;
flex: 1 1 auto; flex: 1 1 auto;
width: 100%; width: 100%;
max-width: 1.8rem; max-width: 1.8rem;
min-height: 9rem; height: 150px;
margin: 0 auto; margin: 0 auto;
border-bottom: 2px solid color-mix(in srgb, var(--primary, #8b5cf6) 75%, transparent); border-bottom: 2px solid color-mix(in srgb, var(--primary, #8b5cf6) 75%, transparent);
border-radius: 0.45rem 0.45rem 0 0; border-radius: 0.45rem 0.45rem 0 0;
@@ -228,12 +230,8 @@
.bar-chart-bar { .bar-chart-bar {
display: block; display: block;
position: absolute;
left: 0;
right: 0;
bottom: 0;
width: 100%; width: 100%;
height: var(--bar-height, 0%); min-height: 0;
border-radius: 6px 6px 0 0; border-radius: 6px 6px 0 0;
background: linear-gradient(180deg, var(--primary-hover, #7c3aed), var(--primary, #8b5cf6)); background: linear-gradient(180deg, var(--primary-hover, #7c3aed), var(--primary, #8b5cf6));
box-shadow: 0 0 18px color-mix(in srgb, var(--primary, #8b5cf6) 35%, transparent); box-shadow: 0 0 18px color-mix(in srgb, var(--primary, #8b5cf6) 35%, transparent);
@@ -278,6 +276,8 @@
} }
.stat-bar-track { .stat-bar-track {
display: block;
width: 100%;
margin-top: 0.35rem; margin-top: 0.35rem;
height: 0.55rem; height: 0.55rem;
border-radius: 999px; border-radius: 999px;
@@ -288,6 +288,7 @@
.stat-bar-fill { .stat-bar-fill {
display: block; display: block;
height: 100%; height: 100%;
min-width: 0;
border-radius: 999px; border-radius: 999px;
background: var(--primary, #8b5cf6); background: var(--primary, #8b5cf6);
} }

View File

@@ -0,0 +1,57 @@
(function () {
const maxBarHeight = 150;
function numberAttr(element, name) {
const value = Number(element.getAttribute(name));
return Number.isFinite(value) ? value : 0;
}
function applyChartBars() {
document.querySelectorAll(".bar-chart").forEach((chart) => {
const bars = Array.from(chart.querySelectorAll(".bar-chart-col"));
const maxValue = Math.max(0, ...bars.map((bar) => numberAttr(bar, "data-chart-value")));
bars.forEach((bar) => {
const fill = bar.querySelector(".bar-chart-bar");
if (!fill) {
return;
}
const value = numberAttr(bar, "data-chart-value");
let height = numberAttr(fill, "data-height-px");
if (maxValue > 0) {
height = value <= 0 ? 0 : Math.max(8, Math.round((value / maxValue) * maxBarHeight));
}
fill.style.height = `${Math.min(maxBarHeight, height)}px`;
});
});
}
function applyStatusBars() {
const rows = Array.from(document.querySelectorAll(".stat-bar"));
const maxValue = Math.max(0, ...rows.map((row) => numberAttr(row, "data-stat-value")));
rows.forEach((row) => {
const fill = row.querySelector(".stat-bar-fill");
if (!fill) {
return;
}
const value = numberAttr(row, "data-stat-value");
let width = numberAttr(fill, "data-width-percent");
if (maxValue > 0) {
width = value <= 0 ? 0 : Math.round((value / maxValue) * 100);
}
fill.style.width = `${Math.max(0, Math.min(100, width))}%`;
});
}
function init() {
applyChartBars();
applyStatusBars();
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();

View File

@@ -32,6 +32,7 @@
<script defer src="/static/js/00-utils.js?version={{.AppVersion}}"></script> <script defer src="/static/js/00-utils.js?version={{.AppVersion}}"></script>
<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/25-admin-charts.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/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>

View File

@@ -65,9 +65,9 @@
<p class="muted-copy">New boxes created over the last 14 days.</p> <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"> <div class="bar-chart" role="img" aria-label="Uploads per day for the last 14 days">
{{range .Data.Overview.UploadDays}} {{range .Data.Overview.UploadDays}}
<div class="bar-chart-col" title="{{.Label}}: {{.Value}}"> <div class="bar-chart-col" title="{{.Label}}: {{.Value}}" data-chart-value="{{.RawValue}}">
<span class="bar-chart-value">{{.Value}}</span> <span class="bar-chart-value">{{.Value}}</span>
<span class="bar-chart-track"><span class="bar-chart-bar" style="--bar-height: {{.Height}}%"></span></span> <span class="bar-chart-track"><span class="bar-chart-bar" data-height-px="{{.HeightPx}}" style="height: {{.HeightPx}}px"></span></span>
<span class="bar-chart-label">{{.Label}}</span> <span class="bar-chart-label">{{.Label}}</span>
</div> </div>
{{end}} {{end}}
@@ -81,9 +81,9 @@
<p class="muted-copy">Share of all {{.Data.Stats.TotalBoxes}} boxes.</p> <p class="muted-copy">Share of all {{.Data.Stats.TotalBoxes}} boxes.</p>
<div class="stat-bars"> <div class="stat-bars">
{{range .Data.Overview.StatusBars}} {{range .Data.Overview.StatusBars}}
<div class="stat-bar"> <div class="stat-bar" data-stat-value="{{.RawValue}}">
<span>{{.Label}} <strong>{{.Value}}</strong></span> <span>{{.Label}} <strong>{{.Value}}</strong></span>
<span class="stat-bar-track"><span class="stat-bar-fill" style="width: {{.Percent}}%"></span></span> <span class="stat-bar-track"><span class="stat-bar-fill" data-width-percent="{{.WidthPercent}}" style="width: {{.WidthPercent}}%"></span></span>
</div> </div>
{{end}} {{end}}
</div> </div>
@@ -97,9 +97,9 @@
<p class="muted-copy">Bytes uploaded over the last 14 days.</p> <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"> <div class="bar-chart" role="img" aria-label="Storage added per day for the last 14 days">
{{range .Data.Overview.StorageDays}} {{range .Data.Overview.StorageDays}}
<div class="bar-chart-col" title="{{.Label}}: {{.Value}}"> <div class="bar-chart-col" title="{{.Label}}: {{.Value}}" data-chart-value="{{.RawValue}}">
<span class="bar-chart-value">{{.Value}}</span> <span class="bar-chart-value">{{.Value}}</span>
<span class="bar-chart-track"><span class="bar-chart-bar" style="--bar-height: {{.Height}}%"></span></span> <span class="bar-chart-track"><span class="bar-chart-bar" data-height-px="{{.HeightPx}}" style="height: {{.HeightPx}}px"></span></span>
<span class="bar-chart-label">{{.Label}}</span> <span class="bar-chart-label">{{.Label}}</span>
</div> </div>
{{end}} {{end}}