import { useEffect, useState, useCallback, useMemo } from 'react'; import { useParams, useLocation, Link } from 'react-router-dom'; import { api } from '../api/client'; import type { Artifact } from '../api/types'; 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 location = useLocation(); const isLocal = location.pathname.startsWith('/locals/'); const backLink = isLocal ? `/locals/${name}` : `/remotes/${name}`; const [artifacts, setArtifacts] = useState([]); const [loading, setLoading] = useState(true); const [filter, setFilter] = useState(''); const [expanded, setExpanded] = useState>(new Set()); const load = useCallback(() => { if (!name) return; setLoading(true); const req = isLocal ? api.listLocalObjects(name, 1, 5000) : api.listObjects(name, 1, 5000); req .then(a => setArtifacts(a || [])) .finally(() => setLoading(false)); }, [name, isLocal]); useEffect(() => { load(); }, [load]); const handleEvict = async (path: string) => { if (!name || !confirm(`Evict ${path}?`)) return; await (isLocal ? api.evictLocalObject(name, path) : api.evictObject(name, path)); load(); }; 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 (
← {name}

Cached Objects

setFilter(e.target.value)} /> {filtered ? filtered.fileCount : 0} objects {filtered && filtered.fileCount > 0 && <> · {formatBytes(filtered.totalSize)}}
{loading ? (
Loading...
) : (
{topChildren.length === 0 ? ( ) : ( topChildren.map(child => ( )) )}
Path Size Hash Last Accessed Hits
No cached objects
)}
); }