Skip to content

Commit 69ff2ae

Browse files
committed
feat: statistics and some UI changes
1 parent 95ee05e commit 69ff2ae

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

50 files changed

+4509
-284
lines changed

backend/internal/api/directory.go

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,12 @@ import (
1010
"os"
1111
"path/filepath"
1212
"sort"
13+
"strings"
1314
"time"
1415

16+
"linux-iso-manager/internal/db"
17+
"linux-iso-manager/internal/service"
18+
1519
"github.com/gin-gonic/gin"
1620
)
1721

@@ -29,8 +33,47 @@ type FileInfo struct {
2933
IsDir bool
3034
}
3135

36+
// DirectoryHandlerConfig holds dependencies for the directory handler.
37+
type DirectoryHandlerConfig struct {
38+
ISODir string
39+
StatsService *service.StatsService
40+
DB *db.DB
41+
}
42+
43+
// isTrackableFile checks if the file should be tracked for download statistics.
44+
// Only tracks actual ISO/image files, not checksum files.
45+
func isTrackableFile(filename string) bool {
46+
ext := strings.ToLower(filepath.Ext(filename))
47+
trackableExtensions := []string{".iso", ".qcow2", ".vmdk", ".img"}
48+
for _, trackable := range trackableExtensions {
49+
if ext == trackable {
50+
return true
51+
}
52+
}
53+
return false
54+
}
55+
56+
// trackDownload records the download asynchronously.
57+
func trackDownload(cfg *DirectoryHandlerConfig, filePath string) {
58+
// Look up the ISO by file path
59+
iso, err := cfg.DB.GetISOByFilePath(filePath)
60+
if err != nil {
61+
slog.Warn("failed to lookup ISO for download tracking", slog.String("path", filePath), slog.Any("error", err))
62+
return
63+
}
64+
if iso == nil {
65+
// ISO not found in database - might be a manually added file
66+
return
67+
}
68+
69+
// Record the download
70+
if err := cfg.StatsService.RecordDownload(iso.ID); err != nil {
71+
slog.Warn("failed to record download", slog.String("iso_id", iso.ID), slog.Any("error", err))
72+
}
73+
}
74+
3275
// DirectoryHandler serves Apache-style directory listing for /images/.
33-
func DirectoryHandler(isoDir string) gin.HandlerFunc {
76+
func DirectoryHandler(cfg *DirectoryHandlerConfig) gin.HandlerFunc {
3477
return func(c *gin.Context) {
3578
// Get the requested path (Gin includes leading slash in wildcard)
3679
requestPath := c.Param("filepath")
@@ -45,7 +88,7 @@ func DirectoryHandler(isoDir string) gin.HandlerFunc {
4588
}
4689

4790
// Construct full filesystem path
48-
fullPath := filepath.Join(isoDir, requestPath)
91+
fullPath := filepath.Join(cfg.ISODir, requestPath)
4992

5093
// Check if path exists
5194
info, err := os.Stat(fullPath)
@@ -56,6 +99,10 @@ func DirectoryHandler(isoDir string) gin.HandlerFunc {
5699

57100
// If it's a file, serve it directly
58101
if !info.IsDir() {
102+
// Track download if it's a trackable ISO file
103+
if isTrackableFile(requestPath) && cfg.StatsService != nil && cfg.DB != nil {
104+
go trackDownload(cfg, requestPath)
105+
}
59106
c.File(fullPath)
60107
return
61108
}

backend/internal/api/directory_test.go

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ func TestDirectoryHandlerListRoot(t *testing.T) {
4040
c.Request, _ = http.NewRequest("GET", "/images/", http.NoBody)
4141
c.Params = gin.Params{{Key: "filepath", Value: "/"}}
4242

43-
handler := DirectoryHandler(isoDir)
43+
handler := DirectoryHandler(&DirectoryHandlerConfig{ISODir: isoDir})
4444
handler(c)
4545

4646
if w.Code != http.StatusOK {
@@ -78,7 +78,7 @@ func TestDirectoryHandlerListSubdirectory(t *testing.T) {
7878
c.Request, _ = http.NewRequest("GET", "/images/alpine", http.NoBody)
7979
c.Params = gin.Params{{Key: "filepath", Value: "/alpine"}}
8080

81-
handler := DirectoryHandler(isoDir)
81+
handler := DirectoryHandler(&DirectoryHandlerConfig{ISODir: isoDir})
8282
handler(c)
8383

8484
if w.Code != http.StatusOK {
@@ -107,7 +107,7 @@ func TestDirectoryHandlerServeFile(t *testing.T) {
107107
c.Request, _ = http.NewRequest("GET", "/images/alpine/3.19.1/x86_64/alpine.iso", http.NoBody)
108108
c.Params = gin.Params{{Key: "filepath", Value: "/alpine/3.19.1/x86_64/alpine.iso"}}
109109

110-
handler := DirectoryHandler(isoDir)
110+
handler := DirectoryHandler(&DirectoryHandlerConfig{ISODir: isoDir})
111111
handler(c)
112112

113113
if w.Code != http.StatusOK {
@@ -130,7 +130,7 @@ func TestDirectoryHandlerFileNotFound(t *testing.T) {
130130
c.Request, _ = http.NewRequest("GET", "/images/nonexistent.iso", http.NoBody)
131131
c.Params = gin.Params{{Key: "filepath", Value: "/nonexistent.iso"}}
132132

133-
handler := DirectoryHandler(isoDir)
133+
handler := DirectoryHandler(&DirectoryHandlerConfig{ISODir: isoDir})
134134
handler(c)
135135

136136
if w.Code != http.StatusNotFound {
@@ -148,7 +148,7 @@ func TestDirectoryHandlerDirectoryNotFound(t *testing.T) {
148148
c.Request, _ = http.NewRequest("GET", "/images/nonexistent/", http.NoBody)
149149
c.Params = gin.Params{{Key: "filepath", Value: "/nonexistent/"}}
150150

151-
handler := DirectoryHandler(isoDir)
151+
handler := DirectoryHandler(&DirectoryHandlerConfig{ISODir: isoDir})
152152
handler(c)
153153

154154
if w.Code != http.StatusNotFound {
@@ -170,7 +170,7 @@ func TestDirectoryHandlerHiddenFilesSkipped(t *testing.T) {
170170
c.Request, _ = http.NewRequest("GET", "/images/", http.NoBody)
171171
c.Params = gin.Params{{Key: "filepath", Value: "/"}}
172172

173-
handler := DirectoryHandler(isoDir)
173+
handler := DirectoryHandler(&DirectoryHandlerConfig{ISODir: isoDir})
174174
handler(c)
175175

176176
body := w.Body.String()
@@ -223,7 +223,7 @@ func TestDirectoryHandlerSorting(t *testing.T) {
223223
c.Request, _ = http.NewRequest("GET", "/images/", http.NoBody)
224224
c.Params = gin.Params{{Key: "filepath", Value: "/"}}
225225

226-
handler := DirectoryHandler(tmpDir)
226+
handler := DirectoryHandler(&DirectoryHandlerConfig{ISODir: tmpDir})
227227
handler(c)
228228

229229
body := w.Body.String()
@@ -252,7 +252,7 @@ func TestDirectoryHandlerEmptyDirectory(t *testing.T) {
252252
c.Request, _ = http.NewRequest("GET", "/images/", http.NoBody)
253253
c.Params = gin.Params{{Key: "filepath", Value: "/"}}
254254

255-
handler := DirectoryHandler(tmpDir)
255+
handler := DirectoryHandler(&DirectoryHandlerConfig{ISODir: tmpDir})
256256
handler(c)
257257

258258
if w.Code != http.StatusOK {
@@ -280,7 +280,7 @@ func TestDirectoryHandlerContentType(t *testing.T) {
280280
c.Request, _ = http.NewRequest("GET", "/images/", http.NoBody)
281281
c.Params = gin.Params{{Key: "filepath", Value: "/"}}
282282

283-
handler := DirectoryHandler(tmpDir)
283+
handler := DirectoryHandler(&DirectoryHandlerConfig{ISODir: tmpDir})
284284
handler(c)
285285

286286
contentType := w.Header().Get("Content-Type")
@@ -299,7 +299,7 @@ func TestDirectoryHandlerNestedPath(t *testing.T) {
299299
c.Request, _ = http.NewRequest("GET", "/images/alpine/3.19.1/x86_64", http.NoBody)
300300
c.Params = gin.Params{{Key: "filepath", Value: "/alpine/3.19.1/x86_64"}}
301301

302-
handler := DirectoryHandler(isoDir)
302+
handler := DirectoryHandler(&DirectoryHandlerConfig{ISODir: isoDir})
303303
handler(c)
304304

305305
if w.Code != http.StatusOK {
@@ -336,7 +336,7 @@ func TestDirectoryHandlerFileTypeIcons(t *testing.T) {
336336
c.Request, _ = http.NewRequest("GET", "/images/", http.NoBody)
337337
c.Params = gin.Params{{Key: "filepath", Value: "/"}}
338338

339-
handler := DirectoryHandler(tmpDir)
339+
handler := DirectoryHandler(&DirectoryHandlerConfig{ISODir: tmpDir})
340340
handler(c)
341341

342342
body := w.Body.String()
@@ -391,7 +391,7 @@ func TestDirectoryHandlerDirectorySizeDisplay(t *testing.T) {
391391
c.Request, _ = http.NewRequest("GET", "/images/", http.NoBody)
392392
c.Params = gin.Params{{Key: "filepath", Value: "/"}}
393393

394-
handler := DirectoryHandler(tmpDir)
394+
handler := DirectoryHandler(&DirectoryHandlerConfig{ISODir: tmpDir})
395395
handler(c)
396396

397397
body := w.Body.String()

backend/internal/api/handlers.go

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,12 @@ package api
33
import (
44
"errors"
55
"net/http"
6+
"strconv"
67
"strings"
78
"time"
89

910
"linux-iso-manager/internal/constants"
11+
"linux-iso-manager/internal/db"
1012
"linux-iso-manager/internal/fileutil"
1113
"linux-iso-manager/internal/models"
1214
"linux-iso-manager/internal/pathutil"
@@ -30,16 +32,49 @@ func NewHandlers(isoService *service.ISOService, isoDir string) *Handlers {
3032
}
3133
}
3234

33-
// ListISOs returns all ISOs ordered by created_at DESC.
35+
// ListISOs returns ISOs with optional pagination and sorting.
36+
// Query params: page (default 1), page_size (default 10), sort_by, sort_dir (asc/desc)
3437
func (h *Handlers) ListISOs(c *gin.Context) {
35-
isos, err := h.isoService.ListISOs()
38+
// Parse pagination parameters
39+
page := 1
40+
if pageStr := c.Query("page"); pageStr != "" {
41+
if p, err := strconv.Atoi(pageStr); err == nil && p > 0 {
42+
page = p
43+
}
44+
}
45+
46+
pageSize := 10
47+
if pageSizeStr := c.Query("page_size"); pageSizeStr != "" {
48+
if ps, err := strconv.Atoi(pageSizeStr); err == nil && ps > 0 {
49+
pageSize = ps
50+
}
51+
}
52+
53+
// Parse sorting parameters
54+
sortBy := c.DefaultQuery("sort_by", "created_at")
55+
sortDir := c.DefaultQuery("sort_dir", "desc")
56+
57+
params := db.ListISOsParams{
58+
Page: page,
59+
PageSize: pageSize,
60+
SortBy: sortBy,
61+
SortDir: sortDir,
62+
}
63+
64+
result, err := h.isoService.ListISOsPaginated(params)
3665
if err != nil {
3766
ErrorResponse(c, http.StatusInternalServerError, ErrCodeInternalError, "Failed to list ISOs")
3867
return
3968
}
4069

4170
SuccessResponse(c, http.StatusOK, gin.H{
42-
"isos": isos,
71+
"isos": result.ISOs,
72+
"pagination": gin.H{
73+
"page": result.Page,
74+
"page_size": result.PageSize,
75+
"total": result.Total,
76+
"total_pages": result.TotalPages,
77+
},
4378
})
4479
}
4580

backend/internal/api/routes.go

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package api
22

33
import (
44
"linux-iso-manager/internal/config"
5+
"linux-iso-manager/internal/db"
56
"linux-iso-manager/internal/service"
67
"linux-iso-manager/internal/ws"
78

@@ -10,7 +11,7 @@ import (
1011
)
1112

1213
// SetupRoutes configures all routes and middleware.
13-
func SetupRoutes(isoService *service.ISOService, isoDir string, wsHub *ws.Hub, cfg *config.Config) *gin.Engine {
14+
func SetupRoutes(isoService *service.ISOService, statsService *service.StatsService, database *db.DB, isoDir string, wsHub *ws.Hub, cfg *config.Config) *gin.Engine {
1415
// Set Gin to release mode for production (can be overridden by GIN_MODE env var)
1516
// gin.SetMode(gin.ReleaseMode)
1617

@@ -25,6 +26,7 @@ func SetupRoutes(isoService *service.ISOService, isoDir string, wsHub *ws.Hub, c
2526

2627
// Create handlers
2728
handlers := NewHandlers(isoService, isoDir)
29+
statsHandlers := NewStatsHandlers(statsService)
2830

2931
// API routes
3032
api := router.Group("/api")
@@ -36,6 +38,10 @@ func SetupRoutes(isoService *service.ISOService, isoDir string, wsHub *ws.Hub, c
3638
api.PUT("/isos/:id", handlers.UpdateISO)
3739
api.DELETE("/isos/:id", handlers.DeleteISO)
3840
api.POST("/isos/:id/retry", handlers.RetryISO)
41+
42+
// Statistics
43+
api.GET("/stats", statsHandlers.GetStats)
44+
api.GET("/stats/trends", statsHandlers.GetDownloadTrends)
3945
}
4046

4147
// WebSocket endpoint
@@ -46,9 +52,14 @@ func SetupRoutes(isoService *service.ISOService, isoDir string, wsHub *ws.Hub, c
4652
// Health check
4753
router.GET("/health", handlers.HealthCheck)
4854

49-
// Static file serving and directory listing
55+
// Static file serving and directory listing with download tracking
5056
// This handles both /images/ (directory listing) and /images/* (file downloads)
51-
router.GET("/images/*filepath", DirectoryHandler(isoDir))
57+
dirConfig := &DirectoryHandlerConfig{
58+
ISODir: isoDir,
59+
StatsService: statsService,
60+
DB: database,
61+
}
62+
router.GET("/images/*filepath", DirectoryHandler(dirConfig))
5263

5364
// Serve frontend static files
5465
// In production, frontend is built into ui/dist

backend/internal/api/routes_test.go

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,20 @@ import (
77
"path/filepath"
88
"testing"
99

10+
"github.com/gin-gonic/gin"
11+
1012
"linux-iso-manager/internal/download"
1113
"linux-iso-manager/internal/service"
1214
"linux-iso-manager/internal/testutil"
1315
"linux-iso-manager/internal/ws"
1416
)
1517

18+
// Helper function to create SetupRoutes with test defaults
19+
func setupTestRouter(env *testutil.TestEnv, isoService *service.ISOService, wsHub *ws.Hub) *gin.Engine {
20+
statsService := service.NewStatsService(env.DB)
21+
return SetupRoutes(isoService, statsService, env.DB, env.ISODir, wsHub, env.Config)
22+
}
23+
1624
func TestSetupRoutes(t *testing.T) {
1725
env := testutil.SetupTestEnvironment(t)
1826
defer env.Cleanup()
@@ -23,7 +31,7 @@ func TestSetupRoutes(t *testing.T) {
2331
isoService := service.NewISOService(env.DB, manager, env.ISODir)
2432

2533
wsHub := ws.NewHub()
26-
router := SetupRoutes(isoService, env.ISODir, wsHub, env.Config)
34+
router := setupTestRouter(env, isoService, wsHub)
2735

2836
if router == nil {
2937
t.Fatal("Expected router to be created, got nil")
@@ -39,7 +47,7 @@ func TestAPIRoutes(t *testing.T) {
3947
isoService := service.NewISOService(env.DB, manager, env.ISODir)
4048

4149
wsHub := ws.NewHub()
42-
router := SetupRoutes(isoService, env.ISODir, wsHub, env.Config)
50+
router := setupTestRouter(env, isoService, wsHub)
4351

4452
tests := []struct {
4553
name string
@@ -108,7 +116,7 @@ func TestAPIRouteNotFound(t *testing.T) {
108116
isoService := service.NewISOService(env.DB, manager, env.ISODir)
109117

110118
wsHub := ws.NewHub()
111-
router := SetupRoutes(isoService, env.ISODir, wsHub, env.Config)
119+
router := setupTestRouter(env, isoService, wsHub)
112120

113121
req := httptest.NewRequest(http.MethodGet, "/api/nonexistent", http.NoBody)
114122
w := httptest.NewRecorder()
@@ -134,7 +142,7 @@ func TestCORSConfiguration(t *testing.T) {
134142
isoService := service.NewISOService(env.DB, manager, env.ISODir)
135143

136144
wsHub := ws.NewHub()
137-
router := SetupRoutes(isoService, env.ISODir, wsHub, env.Config)
145+
router := setupTestRouter(env, isoService, wsHub)
138146

139147
// Test CORS preflight request
140148
req := httptest.NewRequest(http.MethodOptions, "/api/isos", http.NoBody)
@@ -174,7 +182,7 @@ func TestNoRouteHandler(t *testing.T) {
174182
isoService := service.NewISOService(env.DB, manager, env.ISODir)
175183

176184
wsHub := ws.NewHub()
177-
router := SetupRoutes(isoService, env.ISODir, wsHub, env.Config)
185+
router := setupTestRouter(env, isoService, wsHub)
178186

179187
tests := []struct {
180188
name string
@@ -249,7 +257,7 @@ func TestHealthEndpoint(t *testing.T) {
249257
isoService := service.NewISOService(env.DB, manager, env.ISODir)
250258

251259
wsHub := ws.NewHub()
252-
router := SetupRoutes(isoService, env.ISODir, wsHub, env.Config)
260+
router := setupTestRouter(env, isoService, wsHub)
253261

254262
req := httptest.NewRequest(http.MethodGet, "/health", http.NoBody)
255263
w := httptest.NewRecorder()
@@ -281,7 +289,7 @@ func TestImagesRoute(t *testing.T) {
281289
isoService := service.NewISOService(env.DB, manager, env.ISODir)
282290

283291
wsHub := ws.NewHub()
284-
router := SetupRoutes(isoService, env.ISODir, wsHub, env.Config)
292+
router := setupTestRouter(env, isoService, wsHub)
285293

286294
// Test directory listing
287295
req := httptest.NewRequest(http.MethodGet, "/images/", http.NoBody)

0 commit comments

Comments
 (0)