feat: tree view for cached objects, top-files stats on dashboard (#48)

- 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

---------

Co-authored-by: Ben Vincent <ben@unkin.net>
Reviewed-on: #48
This commit was merged in pull request #48.
This commit is contained in:
2026-06-22 22:49:56 +10:00
parent b46c116f6b
commit a481a5c3b7
9 changed files with 508 additions and 101 deletions
+245 -81
View File
@@ -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<string, TreeNode>;
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<string, TreeNode> = 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<string>;
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 (
<>
<tr className={isDir ? 'tree-dir' : 'tree-file'} onClick={isDir ? () => onToggle(node.path) : undefined}>
<td>
<span style={{ paddingLeft: depth * 20 }} className="tree-label">
{isDir && (
<span className="tree-toggle">{isExpanded ? '▾' : '▸'}</span>
)}
<span className={isDir ? 'tree-dir-name' : 'mono tree-file-name'}>
{node.name}{isDir ? '/' : ''}
</span>
</span>
</td>
<td className="num-cell">{formatBytes(node.totalSize)}</td>
{node.artifact ? (
<>
<td className="mono hash-cell" title={node.artifact.content_hash}>
{truncateHash(node.artifact.content_hash)}
</td>
<td>{timeAgo(node.artifact.last_accessed_at)}</td>
<td className="num-cell">{node.artifact.access_count}</td>
<td>
<button
className="btn-evict"
onClick={(e) => { e.stopPropagation(); onEvict(node.artifact!.path); }}
>
Evict
</button>
</td>
</>
) : (
<>
<td className="tree-summary">{node.fileCount} files</td>
<td></td>
<td className="num-cell">{node.totalHits}</td>
<td></td>
</>
)}
</tr>
{isDir && isExpanded && sortedChildren.map(child => (
<TreeRow
key={child.path}
node={child}
depth={depth + 1}
expanded={expanded}
onToggle={onToggle}
onEvict={onEvict}
/>
))}
</>
);
}
export function Objects() {
const { name } = useParams<{ name: string }>();
const [artifacts, setArtifacts] = useState<Artifact[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [filter, setFilter] = useState('');
const [expanded, setExpanded] = useState<Set<string>>(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<string>();
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 (
<div>
@@ -47,84 +244,51 @@ export function Objects() {
value={filter}
onChange={e => setFilter(e.target.value)}
/>
<span className="result-count">{filtered.length} objects</span>
<span className="result-count">
{filtered ? filtered.fileCount : 0} objects
{filtered && filtered.fileCount > 0 && <> &middot; {formatBytes(filtered.totalSize)}</>}
</span>
<div className="tree-controls">
<button className="btn btn-sm" onClick={expandAll}>Expand All</button>
<button className="btn btn-sm" onClick={collapseAll}>Collapse All</button>
</div>
</div>
{loading ? (
<div className="loading">Loading...</div>
) : (
<>
<DataTable
columns={[
{
key: 'path',
header: 'Path',
render: (a: Artifact) => <span className="mono obj-path">{a.path}</span>,
},
{
key: 'size',
header: 'Size',
render: (a: Artifact) => formatBytes(a.size_bytes),
width: '100px',
},
{
key: 'hash',
header: 'Hash',
render: (a: Artifact) => (
<span className="mono hash-cell" title={a.content_hash}>
{truncateHash(a.content_hash)}
</span>
),
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) => (
<button
className="btn-evict"
onClick={(e) => { e.stopPropagation(); handleEvict(a.path); }}
>
Evict
</button>
),
width: '80px',
},
]}
data={filtered}
emptyMessage="No cached objects"
/>
<div className="pagination">
<button
className="btn btn-sm"
disabled={page === 1}
onClick={() => setPage(p => Math.max(1, p - 1))}
>
Previous
</button>
<span className="page-info">Page {page}</span>
<button
className="btn btn-sm"
disabled={artifacts.length < 50}
onClick={() => setPage(p => p + 1)}
>
Next
</button>
</div>
</>
<div className="data-table-wrap">
<table className="data-table tree-table">
<thead>
<tr>
<th>Path</th>
<th style={{ width: 100 }}>Size</th>
<th style={{ width: 160 }}>Hash</th>
<th style={{ width: 120 }}>Last Accessed</th>
<th style={{ width: 70 }}>Hits</th>
<th style={{ width: 80 }}></th>
</tr>
</thead>
<tbody>
{topChildren.length === 0 ? (
<tr>
<td colSpan={6} className="data-table-empty">No cached objects</td>
</tr>
) : (
topChildren.map(child => (
<TreeRow
key={child.path}
node={child}
depth={0}
expanded={expanded}
onToggle={toggleExpand}
onEvict={handleEvict}
/>
))
)}
</tbody>
</table>
</div>
)}
</div>
);