From 43a200998ff45df5f2f70e9ef2d5a31635ed1414 Mon Sep 17 00:00:00 2001 From: Ben Vincent Date: Sun, 21 Jun 2026 22:58:50 +1000 Subject: [PATCH] feat: tree view for cached objects, top-files stats on dashboard - Objects page renders paths as a collapsible tree instead of flat list with expand/collapse all, aggregated size/hits per directory - Dashboard gains top-files-by-hits and top-files-by-bandwidth tables - Backend: new /api/v2/stats/top-files-by-hits and /api/v2/stats/top-files-by-bandwidth endpoints - Raised per_page max to 5000 for objects listing --- internal/api/v2/objects.go | 2 +- internal/api/v2/stats.go | 20 +++ internal/database/stats.go | 65 ++++++++ ui/src/api/client.ts | 4 +- ui/src/api/types.ts | 14 ++ ui/src/pages/Dashboard.css | 35 ++++ ui/src/pages/Dashboard.tsx | 82 +++++++++- ui/src/pages/Objects.css | 61 +++++-- ui/src/pages/Objects.tsx | 326 ++++++++++++++++++++++++++++--------- 9 files changed, 508 insertions(+), 101 deletions(-) diff --git a/internal/api/v2/objects.go b/internal/api/v2/objects.go index c8f02c4..962e2ef 100644 --- a/internal/api/v2/objects.go +++ b/internal/api/v2/objects.go @@ -28,7 +28,7 @@ func (h *ObjectsHandler) Routes() chi.Router { func (h *ObjectsHandler) list(w http.ResponseWriter, r *http.Request) { remoteName := chi.URLParam(r, "name") limit, _ := strconv.Atoi(r.URL.Query().Get("per_page")) - if limit <= 0 || limit > 100 { + if limit <= 0 || limit > 5000 { limit = 50 } page, _ := strconv.Atoi(r.URL.Query().Get("page")) diff --git a/internal/api/v2/stats.go b/internal/api/v2/stats.go index 06fe7df..286a317 100644 --- a/internal/api/v2/stats.go +++ b/internal/api/v2/stats.go @@ -20,6 +20,8 @@ func (h *StatsHandler) Routes() chi.Router { r := chi.NewRouter() r.Get("/", h.overview) r.Get("/top-remotes", h.topRemotes) + r.Get("/top-files-by-hits", h.topFilesByHits) + r.Get("/top-files-by-bandwidth", h.topFilesByBandwidth) return r } @@ -40,3 +42,21 @@ func (h *StatsHandler) topRemotes(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, remotes) } + +func (h *StatsHandler) topFilesByHits(w http.ResponseWriter, r *http.Request) { + files, err := h.db.GetTopFilesByHits(r.Context(), 10) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, files) +} + +func (h *StatsHandler) topFilesByBandwidth(w http.ResponseWriter, r *http.Request) { + files, err := h.db.GetTopFilesByBandwidth(r.Context(), 10) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + writeJSON(w, http.StatusOK, files) +} diff --git a/internal/database/stats.go b/internal/database/stats.go index 74812b3..d21cb68 100644 --- a/internal/database/stats.go +++ b/internal/database/stats.go @@ -76,3 +76,68 @@ func (db *DB) GetTopRemotes(ctx context.Context, limit int) ([]RemoteStatRow, er } return result, rows.Err() } + +type FileStatRow struct { + RemoteName string `json:"remote_name"` + Path string `json:"path"` + AccessCount int64 `json:"access_count"` + SizeBytes int64 `json:"size_bytes"` +} + +func (db *DB) GetTopFilesByHits(ctx context.Context, limit int) ([]FileStatRow, error) { + rows, err := db.Pool.Query(ctx, ` + SELECT a.remote_name, a.path, a.access_count, b.size_bytes + FROM artifacts a + JOIN blobs b ON a.content_hash = b.content_hash + ORDER BY a.access_count DESC + LIMIT $1 + `, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []FileStatRow + for rows.Next() { + var r FileStatRow + if err := rows.Scan(&r.RemoteName, &r.Path, &r.AccessCount, &r.SizeBytes); err != nil { + return nil, err + } + result = append(result, r) + } + return result, rows.Err() +} + +type BandwidthStatRow struct { + RemoteName string `json:"remote_name"` + Path string `json:"path"` + Bandwidth int64 `json:"bandwidth"` + Requests int64 `json:"requests"` +} + +func (db *DB) GetTopFilesByBandwidth(ctx context.Context, limit int) ([]BandwidthStatRow, error) { + rows, err := db.Pool.Query(ctx, ` + SELECT remote_name, path, + COALESCE(SUM(size_bytes), 0) AS bandwidth, + COUNT(*) AS requests + FROM access_log + WHERE created_at > NOW() - INTERVAL '30 days' + GROUP BY remote_name, path + ORDER BY bandwidth DESC + LIMIT $1 + `, limit) + if err != nil { + return nil, err + } + defer rows.Close() + + var result []BandwidthStatRow + for rows.Next() { + var r BandwidthStatRow + if err := rows.Scan(&r.RemoteName, &r.Path, &r.Bandwidth, &r.Requests); err != nil { + return nil, err + } + result = append(result, r) + } + return result, rows.Err() +} diff --git a/ui/src/api/client.ts b/ui/src/api/client.ts index 29e1495..c5ffa7b 100644 --- a/ui/src/api/client.ts +++ b/ui/src/api/client.ts @@ -1,4 +1,4 @@ -import type { Remote, Virtual, Artifact, OverviewStats, RemoteStatRow, HealthStatus, ProbeResult } from './types'; +import type { Remote, Virtual, Artifact, OverviewStats, RemoteStatRow, FileStatRow, BandwidthStatRow, HealthStatus, ProbeResult } from './types'; const BASE = ''; @@ -19,6 +19,8 @@ export const api = { health: () => fetchJSON('/api/v2/health'), stats: () => fetchJSON('/api/v2/stats'), topRemotes: () => fetchJSON('/api/v2/stats/top-remotes'), + topFilesByHits: () => fetchJSON('/api/v2/stats/top-files-by-hits'), + topFilesByBandwidth: () => fetchJSON('/api/v2/stats/top-files-by-bandwidth'), listRemotes: () => fetchJSON('/api/v2/remotes'), getRemote: (name: string) => fetchJSON(`/api/v2/remotes/${name}`), diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts index dccb1bd..1ad6f74 100644 --- a/ui/src/api/types.ts +++ b/ui/src/api/types.ts @@ -62,6 +62,20 @@ export interface RemoteStatRow { requests_30d: number; } +export interface FileStatRow { + remote_name: string; + path: string; + access_count: number; + size_bytes: number; +} + +export interface BandwidthStatRow { + remote_name: string; + path: string; + bandwidth: number; + requests: number; +} + export interface HealthStatus { status: string; postgres: string; diff --git a/ui/src/pages/Dashboard.css b/ui/src/pages/Dashboard.css index e68cdbb..6ed541a 100644 --- a/ui/src/pages/Dashboard.css +++ b/ui/src/pages/Dashboard.css @@ -45,3 +45,38 @@ font-size: 0.9em; padding: 32px 0; } + +.top-files-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 24px; + margin-top: 8px; +} + +@media (max-width: 1100px) { + .top-files-grid { + grid-template-columns: 1fr; + } +} + +.file-link { + display: flex; + flex-direction: column; + gap: 2px; + text-decoration: none; + color: inherit; +} + +.file-link:hover .file-path { + text-decoration: underline; +} + +.file-remote { + font-size: 0.75em; + color: var(--text-muted); +} + +.file-path { + font-size: 0.82em; + word-break: break-all; +} diff --git a/ui/src/pages/Dashboard.tsx b/ui/src/pages/Dashboard.tsx index 81825c8..1565157 100644 --- a/ui/src/pages/Dashboard.tsx +++ b/ui/src/pages/Dashboard.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; import { api } from '../api/client'; -import type { OverviewStats, RemoteStatRow, HealthStatus } from '../api/types'; +import type { OverviewStats, RemoteStatRow, FileStatRow, BandwidthStatRow, HealthStatus } from '../api/types'; import { StatsCard } from '../components/StatsCard'; import { Badge } from '../components/Badge'; import { DataTable } from '../components/DataTable'; @@ -11,14 +11,24 @@ import './Dashboard.css'; export function Dashboard() { const [stats, setStats] = useState(null); const [topRemotes, setTopRemotes] = useState([]); + const [topFilesByHits, setTopFilesByHits] = useState([]); + const [topFilesByBW, setTopFilesByBW] = useState([]); const [health, setHealth] = useState(null); const [error, setError] = useState(null); useEffect(() => { - Promise.all([api.stats(), api.topRemotes(), api.health()]) - .then(([s, tr, h]) => { + Promise.all([ + api.stats(), + api.topRemotes(), + api.topFilesByHits(), + api.topFilesByBandwidth(), + api.health(), + ]) + .then(([s, tr, tfh, tfb, h]) => { setStats(s); setTopRemotes(tr || []); + setTopFilesByHits(tfh || []); + setTopFilesByBW(tfb || []); setHealth(h); }) .catch(e => setError(e.message)); @@ -87,6 +97,72 @@ export function Dashboard() { data={topRemotes} emptyMessage="No remotes configured yet" /> + +
+
+

Top Files by Hits

+ ( + + {r.remote_name} + {r.path} + + ), + }, + { + key: 'hits', + header: 'Hits', + render: (r: FileStatRow) => formatNumber(r.access_count), + width: '90px', + }, + { + key: 'size', + header: 'Size', + render: (r: FileStatRow) => formatBytes(r.size_bytes), + width: '100px', + }, + ]} + data={topFilesByHits} + emptyMessage="No cached files yet" + /> +
+ +
+

Top Files by Bandwidth (30d)

+ ( + + {r.remote_name} + {r.path} + + ), + }, + { + key: 'bandwidth', + header: 'Bandwidth', + render: (r: BandwidthStatRow) => formatBytes(r.bandwidth), + width: '110px', + }, + { + key: 'requests', + header: 'Requests', + render: (r: BandwidthStatRow) => formatNumber(r.requests), + width: '100px', + }, + ]} + data={topFilesByBW} + emptyMessage="No access data yet" + /> +
+
); } diff --git a/ui/src/pages/Objects.css b/ui/src/pages/Objects.css index 31334d9..fffe84d 100644 --- a/ui/src/pages/Objects.css +++ b/ui/src/pages/Objects.css @@ -3,13 +3,58 @@ align-items: center; gap: 12px; margin-bottom: 16px; + flex-wrap: wrap; } -.obj-path { +.tree-controls { + display: flex; + gap: 6px; + margin-left: auto; +} + +.tree-table .tree-label { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.tree-toggle { + display: inline-block; + width: 16px; + font-size: 0.85em; + color: var(--text-muted); + user-select: none; + flex-shrink: 0; +} + +.tree-dir-name { + font-weight: 600; + color: var(--text-bright); +} + +.tree-file-name { font-size: 0.85em; word-break: break-all; } +.tree-dir { + cursor: pointer; +} + +.tree-dir:hover { + background: var(--bg-elevated); +} + +.tree-summary { + font-size: 0.8em; + color: var(--text-muted); +} + +.num-cell { + font-family: var(--font-mono); + font-size: 0.85em; +} + .hash-cell { font-size: 0.8em; color: var(--text-muted); @@ -30,20 +75,6 @@ background: rgba(239, 68, 68, 0.15); } -.pagination { - display: flex; - align-items: center; - justify-content: center; - gap: 16px; - margin-top: 16px; -} - -.page-info { - font-size: 0.85em; - color: var(--text-muted); - font-family: var(--font-mono); -} - .btn-sm { padding: 5px 12px; font-size: 0.85em; diff --git a/ui/src/pages/Objects.tsx b/ui/src/pages/Objects.tsx index 2e9ee01..e9b4c96 100644 --- a/ui/src/pages/Objects.tsx +++ b/ui/src/pages/Objects.tsx @@ -1,25 +1,188 @@ -import { useEffect, useState, useCallback } from 'react'; +import { useEffect, useState, useCallback, useMemo } from 'react'; import { useParams, Link } from 'react-router-dom'; import { api } from '../api/client'; import type { Artifact } from '../api/types'; -import { DataTable } from '../components/DataTable'; import { formatBytes, timeAgo, truncateHash } from '../components/format'; import './Objects.css'; +interface TreeNode { + name: string; + path: string; + children: Map; + artifact?: Artifact; + totalSize: number; + totalHits: number; + fileCount: number; +} + +function buildTree(artifacts: Artifact[]): TreeNode { + const root: TreeNode = { + name: '', + path: '', + children: new Map(), + totalSize: 0, + totalHits: 0, + fileCount: 0, + }; + + for (const a of artifacts) { + const parts = a.path.split('/').filter(Boolean); + let node = root; + let currentPath = ''; + + for (let i = 0; i < parts.length; i++) { + currentPath += (currentPath ? '/' : '') + parts[i]; + const isLeaf = i === parts.length - 1; + + if (!node.children.has(parts[i])) { + node.children.set(parts[i], { + name: parts[i], + path: currentPath, + children: new Map(), + totalSize: 0, + totalHits: 0, + fileCount: 0, + }); + } + + const child = node.children.get(parts[i])!; + child.totalSize += a.size_bytes; + child.totalHits += a.access_count; + child.fileCount += 1; + + if (isLeaf) { + child.artifact = a; + } + + node = child; + } + + root.totalSize += a.size_bytes; + root.totalHits += a.access_count; + root.fileCount += 1; + } + + return root; +} + +function filterTree(node: TreeNode, query: string): TreeNode | null { + if (!query) return node; + const lower = query.toLowerCase(); + + if (node.artifact) { + if (node.path.toLowerCase().includes(lower)) return node; + return null; + } + + const filtered: Map = new Map(); + let totalSize = 0; + let totalHits = 0; + let fileCount = 0; + + for (const [key, child] of node.children) { + const result = filterTree(child, query); + if (result) { + filtered.set(key, result); + totalSize += result.totalSize; + totalHits += result.totalHits; + fileCount += result.fileCount; + } + } + + if (filtered.size === 0) return null; + + return { ...node, children: filtered, totalSize, totalHits, fileCount }; +} + +interface TreeRowProps { + node: TreeNode; + depth: number; + expanded: Set; + onToggle: (path: string) => void; + onEvict: (path: string) => void; +} + +function TreeRow({ node, depth, expanded, onToggle, onEvict }: TreeRowProps) { + const isDir = node.children.size > 0 && !node.artifact; + const isExpanded = expanded.has(node.path); + + const sortedChildren = useMemo(() => { + if (!isDir) return []; + return Array.from(node.children.values()).sort((a, b) => { + const aIsDir = a.children.size > 0 && !a.artifact; + const bIsDir = b.children.size > 0 && !b.artifact; + if (aIsDir !== bIsDir) return aIsDir ? -1 : 1; + return a.name.localeCompare(b.name); + }); + }, [node.children, isDir]); + + return ( + <> + onToggle(node.path) : undefined}> + + + {isDir && ( + {isExpanded ? '▾' : '▸'} + )} + + {node.name}{isDir ? '/' : ''} + + + + {formatBytes(node.totalSize)} + {node.artifact ? ( + <> + + {truncateHash(node.artifact.content_hash)} + + {timeAgo(node.artifact.last_accessed_at)} + {node.artifact.access_count} + + + + + ) : ( + <> + {node.fileCount} files + + {node.totalHits} + + + )} + + {isDir && isExpanded && sortedChildren.map(child => ( + + ))} + + ); +} + export function Objects() { const { name } = useParams<{ name: string }>(); const [artifacts, setArtifacts] = useState([]); - const [page, setPage] = useState(1); const [loading, setLoading] = useState(true); const [filter, setFilter] = useState(''); + const [expanded, setExpanded] = useState>(new Set()); const load = useCallback(() => { if (!name) return; setLoading(true); - api.listObjects(name, page, 50) + api.listObjects(name, 1, 5000) .then(a => setArtifacts(a || [])) .finally(() => setLoading(false)); - }, [name, page]); + }, [name]); useEffect(() => { load(); }, [load]); @@ -29,9 +192,43 @@ export function Objects() { load(); }; - const filtered = filter - ? artifacts.filter(a => a.path.toLowerCase().includes(filter.toLowerCase())) - : artifacts; + const tree = useMemo(() => buildTree(artifacts), [artifacts]); + const filtered = useMemo(() => filterTree(tree, filter), [tree, filter]); + + const toggleExpand = useCallback((path: string) => { + setExpanded(prev => { + const next = new Set(prev); + if (next.has(path)) next.delete(path); + else next.add(path); + return next; + }); + }, []); + + const expandAll = useCallback(() => { + const allDirs = new Set(); + function walk(node: TreeNode) { + if (node.children.size > 0 && !node.artifact) { + allDirs.add(node.path); + for (const child of node.children.values()) walk(child); + } + } + if (filtered) walk(filtered); + setExpanded(allDirs); + }, [filtered]); + + const collapseAll = useCallback(() => { + setExpanded(new Set()); + }, []); + + const topChildren = useMemo(() => { + if (!filtered) return []; + return Array.from(filtered.children.values()).sort((a, b) => { + const aIsDir = a.children.size > 0 && !a.artifact; + const bIsDir = b.children.size > 0 && !b.artifact; + if (aIsDir !== bIsDir) return aIsDir ? -1 : 1; + return a.name.localeCompare(b.name); + }); + }, [filtered]); return (
@@ -47,84 +244,51 @@ export function Objects() { value={filter} onChange={e => setFilter(e.target.value)} /> - {filtered.length} objects + + {filtered ? filtered.fileCount : 0} objects + {filtered && filtered.fileCount > 0 && <> · {formatBytes(filtered.totalSize)}} + +
+ + +
{loading ? (
Loading...
) : ( - <> - {a.path}, - }, - { - key: 'size', - header: 'Size', - render: (a: Artifact) => formatBytes(a.size_bytes), - width: '100px', - }, - { - key: 'hash', - header: 'Hash', - render: (a: Artifact) => ( - - {truncateHash(a.content_hash)} - - ), - width: '160px', - }, - { - key: 'accessed', - header: 'Last Accessed', - render: (a: Artifact) => timeAgo(a.last_accessed_at), - width: '120px', - }, - { - key: 'hits', - header: 'Hits', - render: (a: Artifact) => a.access_count, - width: '70px', - }, - { - key: 'actions', - header: '', - render: (a: Artifact) => ( - - ), - width: '80px', - }, - ]} - data={filtered} - emptyMessage="No cached objects" - /> - -
- - Page {page} - -
- +
+ + + + + + + + + + + + + {topChildren.length === 0 ? ( + + + + ) : ( + topChildren.map(child => ( + + )) + )} + +
PathSizeHashLast AccessedHits
No cached objects
+
)} );