From 15b22b1035b274f12605c0faab03e583e284b85f Mon Sep 17 00:00:00 2001 From: Bharat Kathi Date: Mon, 31 Aug 2026 17:59:38 -0700 Subject: [PATCH] feat: add uploader and application detail pages --- depot/api/api.go | 3 + depot/api/file.go | 52 +++++ depot/api/stats.go | 29 +++ depot/service/file.go | 20 +- depot/service/stats.go | 57 +++++ web/src/lib/depot.ts | 40 ++++ web/src/pages/AttributionDetailsPage.tsx | 253 +++++++++++++++++++++++ web/src/pages/DashboardPage.tsx | 36 ++-- web/src/router.tsx | 6 + 9 files changed, 476 insertions(+), 20 deletions(-) create mode 100644 web/src/pages/AttributionDetailsPage.tsx diff --git a/depot/api/api.go b/depot/api/api.go index 81c75e2..5659b10 100644 --- a/depot/api/api.go +++ b/depot/api/api.go @@ -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 diff --git a/depot/api/file.go b/depot/api/file.go index f8667b3..2134d90 100644 --- a/depot/api/file.go +++ b/depot/api/file.go @@ -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 + } + c.JSON(http.StatusOK, page) +} + func UploadFile(c *gin.Context) { bucket, ok := findBucket(c) if !ok { diff --git a/depot/api/stats.go b/depot/api/stats.go index 3b45e6c..b72443a 100644 --- a/depot/api/stats.go +++ b/depot/api/stats.go @@ -3,6 +3,7 @@ package api import ( "net/http" "strconv" + "strings" "github.com/gaucho-racing/depot/depot/service" "github.com/gin-gonic/gin" @@ -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) +} diff --git a/depot/service/file.go b/depot/service/file.go index 78c46a3..03a5a31 100644 --- a/depot/service/file.go +++ b/depot/service/file.go @@ -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) { @@ -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) } diff --git a/depot/service/stats.go b/depot/service/stats.go index 47ea284..00bfd6e 100644 --- a/depot/service/stats.go +++ b/depot/service/stats.go @@ -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 { @@ -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"` @@ -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 { diff --git a/web/src/lib/depot.ts b/web/src/lib/depot.ts index 5014690..fa1d94f 100644 --- a/web/src/lib/depot.ts +++ b/web/src/lib/depot.ts @@ -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 @@ -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("/files", { + params: { + ...params, + [kind === "uploader" ? "uploader_entity_id" : "application_client_id"]: identifier, + }, + }) + return response.data +} + export async function uploadFile( bucket: string, input: { @@ -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( + `/stats/${segment}/${encodeURIComponent(identifier)}`, + ) + return response.data +} + export async function getActivity(days = 30) { const response = await api.get("/stats/activity", { params: { days } }) return response.data diff --git a/web/src/pages/AttributionDetailsPage.tsx b/web/src/pages/AttributionDetailsPage.tsx new file mode 100644 index 0000000..18b4d88 --- /dev/null +++ b/web/src/pages/AttributionDetailsPage.tsx @@ -0,0 +1,253 @@ +import { useInfiniteQuery, useQuery } from "@tanstack/react-query" +import { ArrowLeft, FileIcon, Globe, Search } from "lucide-react" +import { useState } from "react" +import { Link, useParams } from "react-router-dom" + +import { ApplicationDisplay } from "@/components/ApplicationDisplay" +import { FileSheet } from "@/components/FileSheet" +import { IdentityDisplay } from "@/components/IdentityDisplay" +import { PageContainer, PageHeader } from "@/components/PageContainer" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { Input } from "@/components/ui/input" +import { Skeleton } from "@/components/ui/skeleton" +import { + formatBytes, + getAttributionStats, + listAttributionFiles, + type DepotFile, +} from "@/lib/depot" +import { useApplicationDirectory, useIdentityDirectory } from "@/lib/directory" + +const PAGE_SIZE = 50 + +type AttributionKind = "uploader" | "application" + +function StatTile({ label, value, loading }: { label: string; value: string; loading: boolean }) { + return ( + + +

{label}

+ {loading ? ( + + ) : ( +

{value}

+ )} +
+
+ ) +} + +function formatUploadRange(first?: string, last?: string) { + if (!first || !last) return "No uploads in buckets you can access." + const firstDate = new Date(first).toLocaleDateString() + const lastDate = new Date(last).toLocaleDateString() + if (firstDate === lastDate) return `Uploads recorded on ${firstDate}.` + return `Uploads recorded from ${firstDate} through ${lastDate}.` +} + +export default function AttributionDetailsPage({ kind }: { kind: AttributionKind }) { + const params = useParams() + const identifier = kind === "uploader" ? params.entityID ?? "" : params.clientID ?? "" + const [query, setQuery] = useState("") + const [submittedQuery, setSubmittedQuery] = useState("") + const [selectedFile, setSelectedFile] = useState(null) + + const identityDirectory = useIdentityDirectory(kind === "uploader" ? [identifier] : []) + const applicationDirectory = useApplicationDirectory(kind === "application" ? [identifier] : []) + const identity = identityDirectory.byID.get(identifier) + const application = applicationDirectory.byClientID.get(identifier) + + const statsQuery = useQuery({ + queryKey: ["attributionStats", kind, identifier], + queryFn: () => getAttributionStats(kind, identifier), + enabled: identifier !== "", + }) + const filesQuery = useInfiniteQuery({ + queryKey: ["attributionFiles", kind, identifier, submittedQuery], + queryFn: ({ pageParam }) => + listAttributionFiles(kind, identifier, { + q: submittedQuery || undefined, + limit: PAGE_SIZE, + offset: pageParam, + }), + initialPageParam: 0, + getNextPageParam: (lastPage) => lastPage.next_offset, + enabled: identifier !== "", + }) + + const stats = statsQuery.data + const files = filesQuery.data?.pages.flatMap((page) => page.files) ?? [] + const displayName = + kind === "uploader" ? identity?.name || identifier : application?.name || identifier + + return ( + + + + Dashboard + + + + + + + {kind === "uploader" ? ( + + ) : ( + + )} + + + +
+ + + + +
+ +

+ {statsQuery.isLoading + ? "Loading upload history..." + : formatUploadRange(stats?.first_upload_at, stats?.last_upload_at)} +

+ +
+ + + Storage by bucket + Active files attributed to {displayName || "this source"}. + + + {statsQuery.isLoading ? ( + Array.from({ length: 3 }).map((_, index) => ) + ) : (stats?.buckets ?? []).length === 0 ? ( +

No bucket activity.

+ ) : ( + stats?.buckets.map((bucket) => ( + + + {bucket.bucket_name} + + + {bucket.file_count} · {formatBytes(bucket.total_bytes)} + + + )) + )} +
+
+ + + + Files + Newest uploads first. + + +
{ + event.preventDefault() + setSubmittedQuery(query.trim()) + }} + > + + setQuery(event.target.value)} + placeholder="Search these files by name or path" + className="h-8 border-0 bg-transparent px-0 shadow-none focus-visible:ring-0" + /> + + + {filesQuery.isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, index) => ( + + ))} +
+ ) : filesQuery.isError ? ( +

+ Could not load files. +

+ ) : files.length === 0 ? ( +
+ + + +

+ {submittedQuery ? "No files match this search" : "No files found"} +

+
+ ) : ( +
    + {files.map((file) => ( +
  • + +
  • + ))} +
+ )} + + {filesQuery.hasNextPage && ( +
+ +
+ )} +
+
+
+ + setSelectedFile(null)} /> +
+ ) +} diff --git a/web/src/pages/DashboardPage.tsx b/web/src/pages/DashboardPage.tsx index dad2a24..cff9c47 100644 --- a/web/src/pages/DashboardPage.tsx +++ b/web/src/pages/DashboardPage.tsx @@ -222,13 +222,17 @@ export default function DashboardPage() { {index + 1} - + + + {entity.file_count} file{entity.file_count === 1 ? "" : "s"} ·{" "} {formatBytes(entity.total_bytes)} @@ -263,13 +267,17 @@ export default function DashboardPage() { {index + 1} - + + + {app.file_count} file{app.file_count === 1 ? "" : "s"} ·{" "} {formatBytes(app.total_bytes)} diff --git a/web/src/router.tsx b/web/src/router.tsx index 5a385b1..75676cc 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -2,6 +2,7 @@ import { createBrowserRouter, Navigate } from "react-router-dom" import { AppShell } from "@/components/AppShell" import { RequireAuth } from "@/components/RequireAuth" +import AttributionDetailsPage from "@/pages/AttributionDetailsPage" import BucketDetailsPage from "@/pages/BucketDetailsPage" import BucketsPage from "@/pages/BucketsPage" import EditBucketPage from "@/pages/EditBucketPage" @@ -22,6 +23,11 @@ export const router = createBrowserRouter([ children: [ { path: "/", element: }, { path: "/dashboard", element: }, + { path: "/uploaders/:entityID", element: }, + { + path: "/applications/:clientID", + element: , + }, { path: "/buckets", element: }, { path: "/buckets/new", element: }, { path: "/buckets/:bucketName", element: },