Add storage analytics page

This commit is contained in:
2026-05-18 01:09:34 +02:00
parent a91b9b36d3
commit 691041814f
9 changed files with 439 additions and 8 deletions

View File

@@ -66,6 +66,12 @@ func main() {
"add": func(a, b int) int { return a + b },
"sub": func(a, b int) int { return a - b },
"humanSize": util.HumanSize,
"percent": func(used, total int64) int {
if total == 0 {
return 0
}
return int((float64(used) / float64(total)) * 100)
},
})
r.LoadHTMLGlob("templates/*.html")

6
go.mod
View File

@@ -9,6 +9,7 @@ require (
github.com/joho/godotenv v1.5.1
github.com/rs/zerolog v1.33.0
golang.org/x/crypto v0.49.0
golang.org/x/sys v0.42.0
gorm.io/driver/mysql v1.6.0
gorm.io/driver/postgres v1.6.0
gorm.io/driver/sqlite v1.6.0
@@ -23,6 +24,7 @@ require (
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
@@ -44,15 +46,17 @@ require (
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/shirou/gopsutil/v3 v3.24.5 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/text v0.35.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)

10
go.sum
View File

@@ -18,6 +18,8 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
@@ -78,6 +80,8 @@ github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
@@ -85,6 +89,8 @@ github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRC
github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/rs/zerolog v1.33.0 h1:1cU2KZkvPxNyfgEmhHAz/1A9Bz+llsdYzklWFzgp0r8=
github.com/rs/zerolog v1.33.0/go.mod h1:/7mN4D5sKwJLZQ2b/znpjC3/GQWY/xaDXUM0kKWRHss=
github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI=
github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -101,6 +107,8 @@ github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
@@ -113,6 +121,8 @@ golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=

View File

@@ -4,9 +4,11 @@ import (
"io"
"os"
"path/filepath"
"sort"
"time"
"github.com/google/uuid"
"github.com/shirou/gopsutil/v3/disk"
)
type Service struct {
@@ -209,3 +211,66 @@ func (s *Service) buildPath(id, filename string) string {
func (s *Service) GetAllFiles() ([]FileRecord, error) {
return s.repo.GetAll()
}
func (s *Service) GetStorageStats() (*StorageStats, error) {
files, err := s.repo.GetAll()
if err != nil {
return nil, err
}
stats := &StorageStats{}
var total int64
var active, deleted int
for _, f := range files {
stats.TotalFiles++
if f.Deleted {
deleted++
} else {
active++
}
total += f.Size
}
stats.TotalBytes = total
stats.ActiveFiles = active
stats.DeletedFiles = deleted
if stats.TotalFiles > 0 {
stats.AverageFileSize = total / int64(stats.TotalFiles)
}
// Biggest files
sort.Slice(files, func(i, j int) bool {
return files[i].Size > files[j].Size
})
if len(files) > 10 {
stats.LargestFiles = files[:10]
} else {
stats.LargestFiles = files
}
usage, err := disk.Usage(s.storageDir)
if err == nil {
stats.DiskTotalBytes = int64(usage.Total)
stats.DiskFreeBytes = int64(usage.Free)
stats.DiskUsedBytes = int64(usage.Used)
}
// tmp chunk usage
var tmpTotal int64
filepath.Walk("tmp", func(path string, info os.FileInfo, err error) error {
if err == nil && !info.IsDir() {
tmpTotal += info.Size()
}
return nil
})
stats.TempBytes = tmpTotal
return stats, nil
}

18
internal/file/stats.go Normal file
View File

@@ -0,0 +1,18 @@
package file
type StorageStats struct {
TotalFiles int
ActiveFiles int
DeletedFiles int
TotalBytes int64
AverageFileSize int64
DiskTotalBytes int64
DiskFreeBytes int64
DiskUsedBytes int64
TempBytes int64
LargestFiles []FileRecord
}

View File

@@ -91,7 +91,7 @@ func (h *Handler) AdminPage(c *gin.Context) {
page = 1
}
limit := 10
limit := 25
offset := (page - 1) * limit
files, totalCount, err := h.fileService.GetPaginatedFiles(limit, offset)
@@ -103,11 +103,6 @@ func (h *Handler) AdminPage(c *gin.Context) {
return
}
// Only check files on the current page.
// Status meanings:
// - green: file exists on disk
// - red: file missing
// - rainbow: stat error (something unexpected)
type AdminFileView struct {
file.FileRecord
ActualStatus string
@@ -153,4 +148,26 @@ func (h *Handler) Logout(c *gin.Context) {
func (h *Handler) ChangePasswordPage(c *gin.Context) {
c.HTML(200, "changePassword.html", nil)
}
}
func (h *Handler) AdminStatsPage(c *gin.Context) {
log := middleware.StructuredLog(c).With().
Str("event", "admin_stats_page").
Logger()
stats, err := h.fileService.GetStorageStats()
if err != nil {
log.Error().Err(err).Msg("Failed to load storage stats")
c.HTML(500, "admin_stats.html", gin.H{
"error": err.Error(),
})
return
}
log.Info().Msg("Admin stats page viewed")
c.HTML(200, "admin_stats.html", gin.H{
"Stats": stats,
})
}

View File

@@ -20,6 +20,7 @@ func RegisterRoutes(r *gin.Engine, h *Handler, userService *user.Service) {
adminRoutes.Use(user.ForcePasswordChangeMiddleware(userService))
adminRoutes.GET("/admin", h.AdminPage)
adminRoutes.GET("/admin/stats", h.AdminStatsPage)
adminRoutes.GET("/config", h.ConfigPage)
adminRoutes.POST("/config", h.ConfigSave)
adminRoutes.GET("/logout", h.Logout)

View File

@@ -121,6 +121,7 @@
<div class="flex flex-col items-end gap-2">
<a href="/" class="nav-link">← BACK_TO_UPLOADER</a>
<a href="/config" class="nav-link">CONFIG_MODULE</a>
<a href="/admin/stats" class="nav-link">STORAGE_MATRIX</a>
<a href="/logout" class="nav-link text-red-600">LOGOUT_SESSION</a>
</div>
</header>

309
templates/admin_stats.html Normal file
View File

@@ -0,0 +1,309 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Storage Matrix</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
* { border-radius: 0 !important; }
body {
font-family: ui-monospace, 'Cascadia Code', 'Source Code Pro', monospace;
background: #fff;
color: #000;
padding: 20px;
}
.box {
border: 4px solid #000;
background: #fff;
}
.nav-link {
font-weight: 900;
text-decoration: underline;
text-transform: uppercase;
font-size: 12px;
}
.nav-link:hover {
background: #000;
color: #fff;
}
table {
width: 100%;
border-collapse: collapse;
}
th {
background: #000;
color: #fff;
text-align: left;
padding: 12px;
font-size: 12px;
text-transform: uppercase;
border: 2px solid #000;
}
td {
border: 2px solid #000;
padding: 12px;
font-size: 13px;
font-weight: 600;
}
tr:hover {
background: #ffff00;
}
.meter {
width: 100%;
height: 40px;
border: 3px solid #000;
background: #fff;
overflow: hidden;
}
.meter-fill {
height: 100%;
background: #000;
}
.status-live {
background: #00ff00;
color: #000;
font-weight: 900;
padding: 4px 8px;
border: 2px solid #000;
}
.status-deleted {
background: #ff0000;
color: #fff;
font-weight: 900;
padding: 4px 8px;
border: 2px solid #000;
}
.glitch {
animation: glitch 0.6s infinite;
}
@keyframes glitch {
0% { transform: translate(0px, 0px); }
20% { transform: translate(-2px, 1px); }
40% { transform: translate(2px, -1px); }
60% { transform: translate(-1px, 2px); }
80% { transform: translate(1px, -2px); }
100% { transform: translate(0px, 0px); }
}
</style>
</head>
<body>
<div class="max-w-7xl mx-auto">
<header class="mb-8 border-b-8 border-black pb-4 flex justify-between items-start">
<div>
<h1 class="text-5xl font-black uppercase tracking-tighter leading-none">
STORAGE_ANALYTICS
</h1>
<div class="text-sm font-black uppercase mt-2 text-gray-600">
</div>
</div>
<div class="flex flex-col items-end gap-2">
<a href="/admin" class="nav-link">← BACK_TO_ADMIN</a>
<a href="/config" class="nav-link">CONFIG_MODULE</a>
<a href="/logout" class="nav-link text-red-600">LOGOUT_SESSION</a>
</div>
</header>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6 mb-8">
<div class="box p-6">
<div class="text-xs font-black uppercase mb-2">
TOTAL_STORAGE_USED
</div>
<div class="text-5xl font-black">
{{humanSize .Stats.TotalBytes}}
</div>
</div>
<div class="box p-6">
<div class="text-xs font-black uppercase mb-2">
DISK_FREE_SPACE
</div>
<div class="text-5xl font-black text-green-600">
{{humanSize .Stats.DiskFreeBytes}}
</div>
</div>
<div class="box p-6">
<div class="text-xs font-black uppercase mb-2">
TEMP_CHUNK_STORAGE
</div>
<div class="text-5xl font-black text-yellow-500">
{{humanSize .Stats.TempBytes}}
</div>
</div>
</div>
<!-- DISK BAR -->
<div class="box p-6 mb-8">
<div class="flex justify-between items-center mb-4">
<div class="text-xl font-black uppercase">
DISK_CONSUMPTION
</div>
<div class="text-sm font-black uppercase">
{{humanSize .Stats.DiskUsedBytes}}
/
{{humanSize .Stats.DiskTotalBytes}}
</div>
</div>
<div class="meter">
<div
class="meter-fill"
style="width: {{percent .Stats.DiskUsedBytes .Stats.DiskTotalBytes}}%;">
</div>
</div>
<div class="mt-4 text-6xl font-black">
{{percent .Stats.DiskUsedBytes .Stats.DiskTotalBytes}}%
</div>
</div>
<!-- SECONDARY STATS -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div class="box p-4">
<div class="text-xs uppercase font-black">
TOTAL_FILES
</div>
<div class="text-4xl font-black mt-2">
{{.Stats.TotalFiles}}
</div>
</div>
<div class="box p-4">
<div class="text-xs uppercase font-black">
ACTIVE_FILES
</div>
<div class="text-4xl font-black text-green-600 mt-2">
{{.Stats.ActiveFiles}}
</div>
</div>
<div class="box p-4">
<div class="text-xs uppercase font-black">
DELETED_FILES
</div>
<div class="text-4xl font-black text-red-600 mt-2">
{{.Stats.DeletedFiles}}
</div>
</div>
<div class="box p-4">
<div class="text-xs uppercase font-black">
AVERAGE_SIZE
</div>
<div class="text-4xl font-black mt-2">
{{humanSize .Stats.AverageFileSize}}
</div>
</div>
</div>
<div class="box overflow-x-auto mb-8">
<div class="bg-black text-white p-4 font-black uppercase text-lg">
LARGEST_FILES_ON_SYSTEM
</div>
<table>
<thead>
<tr>
<th>Filename</th>
<th>Size</th>
<th>Downloads</th>
<th>Created</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{{if not .Stats.LargestFiles}}
<tr>
<td colspan="5" class="text-center py-10 uppercase font-black italic">
No files detected in storage matrix
</td>
</tr>
{{end}}
{{range .Stats.LargestFiles}}
<tr>
<td class="font-bold">
{{.Filename}}
</td>
<td class="whitespace-nowrap">
{{humanSize .Size}}
</td>
<td class="font-black text-lg">
{{.DownloadCount}}
</td>
<td class="text-[11px]">
{{.CreatedAt.Format "02/01/06 15:04"}}
</td>
<td>
{{if .Deleted}}
<span class="status-deleted">
DELETED
</span>
{{else}}
<span class="status-live">
LIVE
</span>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
</div>
<footer class="border-t-8 border-black pt-4 flex justify-between items-center">
</footer>
</div>
</body>
</html>