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:
+245
-81
@@ -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 && <> · {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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user