Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions depot/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ func InitializeRoutes(router *gin.Engine) {

router.GET("/stats", GetStats)
router.GET("/stats/activity", GetActivityStats)
router.GET("/stats/uploaders/:entityID", GetUploaderStats)
router.GET("/stats/applications/:clientID", GetApplicationStats)
router.GET("/files", ListAttributionFiles)
router.GET("/files/search", SearchFiles)

// Files are addressed by id alone here: the id determines the bucket, so a
Expand Down
52 changes: 52 additions & 0 deletions depot/api/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,58 @@ func SearchFiles(c *gin.Context) {
c.JSON(http.StatusOK, files)
}

type attributionFilePage struct {
Files []model.File `json:"files"`
NextOffset *int `json:"next_offset,omitempty"`
}

func ListAttributionFiles(c *gin.Context) {
Require(c, RequestTokenExists(c))

entityID := strings.TrimSpace(c.Query("uploader_entity_id"))
clientID := strings.TrimSpace(c.Query("application_client_id"))
if (entityID == "") == (clientID == "") {
c.JSON(http.StatusBadRequest, gin.H{"error": "provide exactly one uploader_entity_id or application_client_id"})
return
}
limit, offset, ok := parseListParams(c)
if !ok {
return
}

bucketIDs, err := accessibleBucketIDs(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if len(bucketIDs) == 0 {
c.JSON(http.StatusOK, attributionFilePage{Files: []model.File{}})
return
}

files, err := service.ListFiles(service.FileQuery{
BucketIDs: bucketIDs,
Search: strings.TrimSpace(c.Query("q")),
CreatedByEntityID: entityID,
CreatedByClientID: clientID,
Status: model.FileStatusActive,
Limit: limit + 1,
Offset: offset,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}

page := attributionFilePage{Files: files}
if len(files) > limit {
nextOffset := offset + limit
page.Files = files[:limit]
page.NextOffset = &nextOffset
Comment on lines +218 to +220

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Use a stable cursor for attribution pagination

When an upload becomes active between page requests, advancing this numeric offset against ListFiles' live created_at desc ordering shifts every older row, so the next page repeats an item from the previous page while the newly inserted file remains absent from the concatenated results. Equal timestamps also make the ordering nondeterministic. Use a stable (created_at, id) ordering with a cursor or snapshot boundary so loading more cannot produce duplicate or missing entries.

Useful? React with 👍 / 👎.

}
c.JSON(http.StatusOK, page)
}

func UploadFile(c *gin.Context) {
bucket, ok := findBucket(c)
if !ok {
Expand Down
29 changes: 29 additions & 0 deletions depot/api/stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package api
import (
"net/http"
"strconv"
"strings"

"github.com/gaucho-racing/depot/depot/service"
"github.com/gin-gonic/gin"
Expand Down Expand Up @@ -58,3 +59,31 @@ func GetActivityStats(c *gin.Context) {
}
c.JSON(http.StatusOK, points)
}

func GetUploaderStats(c *gin.Context) {
getAttributionStats(c, service.AttributionFilter{EntityID: strings.TrimSpace(c.Param("entityID"))})
}

func GetApplicationStats(c *gin.Context) {
getAttributionStats(c, service.AttributionFilter{ClientID: strings.TrimSpace(c.Param("clientID"))})
}

func getAttributionStats(c *gin.Context, filter service.AttributionFilter) {
Require(c, RequestTokenExists(c))
if filter.EntityID == "" && filter.ClientID == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "attribution identifier is required"})
return
}

bucketIDs, err := accessibleBucketIDs(c)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
stats, err := service.GetAttributionStats(bucketIDs, filter)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, stats)
}
20 changes: 14 additions & 6 deletions depot/service/file.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,14 @@ func GetBucketFileByID(bucketID string, fileID string) (model.File, error) {
}

type FileQuery struct {
BucketIDs []string
PathPrefix string
Search string
Status model.FileStatus
Limit int
Offset int
BucketIDs []string
PathPrefix string
Search string
CreatedByEntityID string
CreatedByClientID string
Status model.FileStatus
Limit int
Offset int
}

func ListFiles(q FileQuery) ([]model.File, error) {
Expand All @@ -60,6 +62,12 @@ func ListFiles(q FileQuery) ([]model.File, error) {
pattern := "%" + q.Search + "%"
query = query.Where("original_name ILIKE ? OR path ILIKE ?", pattern, pattern)
}
if q.CreatedByEntityID != "" {
query = query.Where("created_by_entity_id = ?", q.CreatedByEntityID)
}
if q.CreatedByClientID != "" {
query = query.Where("created_by_client_id = ?", q.CreatedByClientID)
}
if q.Status != "" {
query = query.Where("status = ?", q.Status)
}
Expand Down
57 changes: 57 additions & 0 deletions depot/service/stats.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (

"github.com/gaucho-racing/depot/depot/database"
"github.com/gaucho-racing/depot/depot/model"
"gorm.io/gorm"
)

type BucketStats struct {
Expand Down Expand Up @@ -36,6 +37,21 @@ type Stats struct {
TopApplications []ApplicationStats `json:"top_applications"`
}

type AttributionFilter struct {
EntityID string
ClientID string
}

type AttributionStats struct {
FileCount int64 `json:"file_count"`
TotalBytes int64 `json:"total_bytes"`
BucketCount int64 `json:"bucket_count"`
PublicFiles int64 `json:"public_files"`
FirstUploadAt *time.Time `json:"first_upload_at,omitempty"`
LastUploadAt *time.Time `json:"last_upload_at,omitempty"`
Buckets []BucketStats `json:"buckets"`
}

type ActivityPoint struct {
Date string `json:"date"`
Uploads int64 `json:"uploads"`
Expand Down Expand Up @@ -94,6 +110,47 @@ func GetStats(bucketIDs []string) (Stats, error) {
return stats, nil
}

func GetAttributionStats(bucketIDs []string, filter AttributionFilter) (AttributionStats, error) {
stats := AttributionStats{Buckets: []BucketStats{}}
if len(bucketIDs) == 0 {
return stats, nil
}
if (filter.EntityID == "") == (filter.ClientID == "") {
return AttributionStats{}, fmt.Errorf("exactly one attribution filter is required")
}

query := func() *gorm.DB {
result := database.DB.Model(&model.File{}).
Where("status = ? AND bucket_id IN ?", model.FileStatusActive, bucketIDs)
if filter.EntityID != "" {
return result.Where("created_by_entity_id = ?", filter.EntityID)
}
return result.Where("created_by_client_id = ?", filter.ClientID)
}

if err := query().
Select(`
count(*) AS file_count,
coalesce(sum(size_bytes), 0) AS total_bytes,
count(distinct bucket_id) AS bucket_count,
count(*) FILTER (WHERE public) AS public_files,
min(created_at) AS first_upload_at,
max(created_at) AS last_upload_at
`).
Scan(&stats).Error; err != nil {
return AttributionStats{}, fmt.Errorf("failed to compute attribution totals: %w", err)
}

if err := query().
Select("bucket_id, bucket_name, count(*) as file_count, coalesce(sum(size_bytes), 0) as total_bytes").
Group("bucket_id, bucket_name").
Order("total_bytes desc").
Scan(&stats.Buckets).Error; err != nil {
return AttributionStats{}, fmt.Errorf("failed to compute attribution bucket stats: %w", err)
}
return stats, nil
}

func GetActivityStats(bucketIDs []string, days int) ([]ActivityPoint, error) {
points := []ActivityPoint{}
if len(bucketIDs) == 0 {
Expand Down
40 changes: 40 additions & 0 deletions web/src/lib/depot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,21 @@ export type Stats = {
top_applications: ApplicationStats[]
}

export type AttributionStats = {
file_count: number
total_bytes: number
bucket_count: number
public_files: number
first_upload_at?: string
last_upload_at?: string
buckets: BucketStats[]
}

export type AttributionFilePage = {
files: DepotFile[]
next_offset?: number
}

export type ActivityPoint = {
date: string
uploads: number
Expand Down Expand Up @@ -277,6 +292,20 @@ export async function searchFiles(q: string, limit = 50) {
return response.data
}

export async function listAttributionFiles(
kind: "uploader" | "application",
identifier: string,
params: { q?: string; limit?: number; offset?: number } = {},
) {
const response = await api.get<AttributionFilePage>("/files", {
params: {
...params,
[kind === "uploader" ? "uploader_entity_id" : "application_client_id"]: identifier,
},
})
return response.data
}

export async function uploadFile(
bucket: string,
input: {
Expand Down Expand Up @@ -368,6 +397,17 @@ export async function getStats() {
return response.data
}

export async function getAttributionStats(
kind: "uploader" | "application",
identifier: string,
) {
const segment = kind === "uploader" ? "uploaders" : "applications"
const response = await api.get<AttributionStats>(
`/stats/${segment}/${encodeURIComponent(identifier)}`,
)
return response.data
}

export async function getActivity(days = 30) {
const response = await api.get<ActivityPoint[]>("/stats/activity", { params: { days } })
return response.data
Expand Down
Loading
Loading