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:
@@ -28,7 +28,7 @@ func (h *ObjectsHandler) Routes() chi.Router {
|
|||||||
func (h *ObjectsHandler) list(w http.ResponseWriter, r *http.Request) {
|
func (h *ObjectsHandler) list(w http.ResponseWriter, r *http.Request) {
|
||||||
remoteName := chi.URLParam(r, "name")
|
remoteName := chi.URLParam(r, "name")
|
||||||
limit, _ := strconv.Atoi(r.URL.Query().Get("per_page"))
|
limit, _ := strconv.Atoi(r.URL.Query().Get("per_page"))
|
||||||
if limit <= 0 || limit > 100 {
|
if limit <= 0 || limit > 5000 {
|
||||||
limit = 50
|
limit = 50
|
||||||
}
|
}
|
||||||
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
page, _ := strconv.Atoi(r.URL.Query().Get("page"))
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ func (h *StatsHandler) Routes() chi.Router {
|
|||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Get("/", h.overview)
|
r.Get("/", h.overview)
|
||||||
r.Get("/top-remotes", h.topRemotes)
|
r.Get("/top-remotes", h.topRemotes)
|
||||||
|
r.Get("/top-files-by-hits", h.topFilesByHits)
|
||||||
|
r.Get("/top-files-by-bandwidth", h.topFilesByBandwidth)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,3 +42,21 @@ func (h *StatsHandler) topRemotes(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, remotes)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -76,3 +76,68 @@ func (db *DB) GetTopRemotes(ctx context.Context, limit int) ([]RemoteStatRow, er
|
|||||||
}
|
}
|
||||||
return result, rows.Err()
|
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()
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 = '';
|
const BASE = '';
|
||||||
|
|
||||||
@@ -19,6 +19,8 @@ export const api = {
|
|||||||
health: () => fetchJSON<HealthStatus>('/api/v2/health'),
|
health: () => fetchJSON<HealthStatus>('/api/v2/health'),
|
||||||
stats: () => fetchJSON<OverviewStats>('/api/v2/stats'),
|
stats: () => fetchJSON<OverviewStats>('/api/v2/stats'),
|
||||||
topRemotes: () => fetchJSON<RemoteStatRow[]>('/api/v2/stats/top-remotes'),
|
topRemotes: () => fetchJSON<RemoteStatRow[]>('/api/v2/stats/top-remotes'),
|
||||||
|
topFilesByHits: () => fetchJSON<FileStatRow[]>('/api/v2/stats/top-files-by-hits'),
|
||||||
|
topFilesByBandwidth: () => fetchJSON<BandwidthStatRow[]>('/api/v2/stats/top-files-by-bandwidth'),
|
||||||
|
|
||||||
listRemotes: () => fetchJSON<Remote[]>('/api/v2/remotes'),
|
listRemotes: () => fetchJSON<Remote[]>('/api/v2/remotes'),
|
||||||
getRemote: (name: string) => fetchJSON<Remote>(`/api/v2/remotes/${name}`),
|
getRemote: (name: string) => fetchJSON<Remote>(`/api/v2/remotes/${name}`),
|
||||||
|
|||||||
@@ -62,6 +62,20 @@ export interface RemoteStatRow {
|
|||||||
requests_30d: number;
|
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 {
|
export interface HealthStatus {
|
||||||
status: string;
|
status: string;
|
||||||
postgres: string;
|
postgres: string;
|
||||||
|
|||||||
@@ -45,3 +45,38 @@
|
|||||||
font-size: 0.9em;
|
font-size: 0.9em;
|
||||||
padding: 32px 0;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { api } from '../api/client';
|
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 { StatsCard } from '../components/StatsCard';
|
||||||
import { Badge } from '../components/Badge';
|
import { Badge } from '../components/Badge';
|
||||||
import { DataTable } from '../components/DataTable';
|
import { DataTable } from '../components/DataTable';
|
||||||
@@ -11,14 +11,24 @@ import './Dashboard.css';
|
|||||||
export function Dashboard() {
|
export function Dashboard() {
|
||||||
const [stats, setStats] = useState<OverviewStats | null>(null);
|
const [stats, setStats] = useState<OverviewStats | null>(null);
|
||||||
const [topRemotes, setTopRemotes] = useState<RemoteStatRow[]>([]);
|
const [topRemotes, setTopRemotes] = useState<RemoteStatRow[]>([]);
|
||||||
|
const [topFilesByHits, setTopFilesByHits] = useState<FileStatRow[]>([]);
|
||||||
|
const [topFilesByBW, setTopFilesByBW] = useState<BandwidthStatRow[]>([]);
|
||||||
const [health, setHealth] = useState<HealthStatus | null>(null);
|
const [health, setHealth] = useState<HealthStatus | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Promise.all([api.stats(), api.topRemotes(), api.health()])
|
Promise.all([
|
||||||
.then(([s, tr, h]) => {
|
api.stats(),
|
||||||
|
api.topRemotes(),
|
||||||
|
api.topFilesByHits(),
|
||||||
|
api.topFilesByBandwidth(),
|
||||||
|
api.health(),
|
||||||
|
])
|
||||||
|
.then(([s, tr, tfh, tfb, h]) => {
|
||||||
setStats(s);
|
setStats(s);
|
||||||
setTopRemotes(tr || []);
|
setTopRemotes(tr || []);
|
||||||
|
setTopFilesByHits(tfh || []);
|
||||||
|
setTopFilesByBW(tfb || []);
|
||||||
setHealth(h);
|
setHealth(h);
|
||||||
})
|
})
|
||||||
.catch(e => setError(e.message));
|
.catch(e => setError(e.message));
|
||||||
@@ -87,6 +97,72 @@ export function Dashboard() {
|
|||||||
data={topRemotes}
|
data={topRemotes}
|
||||||
emptyMessage="No remotes configured yet"
|
emptyMessage="No remotes configured yet"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div className="top-files-grid">
|
||||||
|
<div>
|
||||||
|
<h2 className="section-title">Top Files by Hits</h2>
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: 'path',
|
||||||
|
header: 'File',
|
||||||
|
render: (r: FileStatRow) => (
|
||||||
|
<Link to={`/remotes/${r.remote_name}/objects`} className="file-link">
|
||||||
|
<span className="file-remote">{r.remote_name}</span>
|
||||||
|
<span className="file-path mono">{r.path}</span>
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<h2 className="section-title">Top Files by Bandwidth (30d)</h2>
|
||||||
|
<DataTable
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
key: 'path',
|
||||||
|
header: 'File',
|
||||||
|
render: (r: BandwidthStatRow) => (
|
||||||
|
<Link to={`/remotes/${r.remote_name}/objects`} className="file-link">
|
||||||
|
<span className="file-remote">{r.remote_name}</span>
|
||||||
|
<span className="file-path mono">{r.path}</span>
|
||||||
|
</Link>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+46
-15
@@ -3,13 +3,58 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 16px;
|
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;
|
font-size: 0.85em;
|
||||||
word-break: break-all;
|
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 {
|
.hash-cell {
|
||||||
font-size: 0.8em;
|
font-size: 0.8em;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
@@ -30,20 +75,6 @@
|
|||||||
background: rgba(239, 68, 68, 0.15);
|
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 {
|
.btn-sm {
|
||||||
padding: 5px 12px;
|
padding: 5px 12px;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
|
|||||||
+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 { useParams, Link } from 'react-router-dom';
|
||||||
import { api } from '../api/client';
|
import { api } from '../api/client';
|
||||||
import type { Artifact } from '../api/types';
|
import type { Artifact } from '../api/types';
|
||||||
import { DataTable } from '../components/DataTable';
|
|
||||||
import { formatBytes, timeAgo, truncateHash } from '../components/format';
|
import { formatBytes, timeAgo, truncateHash } from '../components/format';
|
||||||
import './Objects.css';
|
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() {
|
export function Objects() {
|
||||||
const { name } = useParams<{ name: string }>();
|
const { name } = useParams<{ name: string }>();
|
||||||
const [artifacts, setArtifacts] = useState<Artifact[]>([]);
|
const [artifacts, setArtifacts] = useState<Artifact[]>([]);
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [filter, setFilter] = useState('');
|
const [filter, setFilter] = useState('');
|
||||||
|
const [expanded, setExpanded] = useState<Set<string>>(new Set());
|
||||||
|
|
||||||
const load = useCallback(() => {
|
const load = useCallback(() => {
|
||||||
if (!name) return;
|
if (!name) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
api.listObjects(name, page, 50)
|
api.listObjects(name, 1, 5000)
|
||||||
.then(a => setArtifacts(a || []))
|
.then(a => setArtifacts(a || []))
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, [name, page]);
|
}, [name]);
|
||||||
|
|
||||||
useEffect(() => { load(); }, [load]);
|
useEffect(() => { load(); }, [load]);
|
||||||
|
|
||||||
@@ -29,9 +192,43 @@ export function Objects() {
|
|||||||
load();
|
load();
|
||||||
};
|
};
|
||||||
|
|
||||||
const filtered = filter
|
const tree = useMemo(() => buildTree(artifacts), [artifacts]);
|
||||||
? artifacts.filter(a => a.path.toLowerCase().includes(filter.toLowerCase()))
|
const filtered = useMemo(() => filterTree(tree, filter), [tree, filter]);
|
||||||
: artifacts;
|
|
||||||
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -47,84 +244,51 @@ export function Objects() {
|
|||||||
value={filter}
|
value={filter}
|
||||||
onChange={e => setFilter(e.target.value)}
|
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>
|
</div>
|
||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="loading">Loading...</div>
|
<div className="loading">Loading...</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<div className="data-table-wrap">
|
||||||
<DataTable
|
<table className="data-table tree-table">
|
||||||
columns={[
|
<thead>
|
||||||
{
|
<tr>
|
||||||
key: 'path',
|
<th>Path</th>
|
||||||
header: 'Path',
|
<th style={{ width: 100 }}>Size</th>
|
||||||
render: (a: Artifact) => <span className="mono obj-path">{a.path}</span>,
|
<th style={{ width: 160 }}>Hash</th>
|
||||||
},
|
<th style={{ width: 120 }}>Last Accessed</th>
|
||||||
{
|
<th style={{ width: 70 }}>Hits</th>
|
||||||
key: 'size',
|
<th style={{ width: 80 }}></th>
|
||||||
header: 'Size',
|
</tr>
|
||||||
render: (a: Artifact) => formatBytes(a.size_bytes),
|
</thead>
|
||||||
width: '100px',
|
<tbody>
|
||||||
},
|
{topChildren.length === 0 ? (
|
||||||
{
|
<tr>
|
||||||
key: 'hash',
|
<td colSpan={6} className="data-table-empty">No cached objects</td>
|
||||||
header: 'Hash',
|
</tr>
|
||||||
render: (a: Artifact) => (
|
) : (
|
||||||
<span className="mono hash-cell" title={a.content_hash}>
|
topChildren.map(child => (
|
||||||
{truncateHash(a.content_hash)}
|
<TreeRow
|
||||||
</span>
|
key={child.path}
|
||||||
),
|
node={child}
|
||||||
width: '160px',
|
depth={0}
|
||||||
},
|
expanded={expanded}
|
||||||
{
|
onToggle={toggleExpand}
|
||||||
key: 'accessed',
|
onEvict={handleEvict}
|
||||||
header: 'Last Accessed',
|
/>
|
||||||
render: (a: Artifact) => timeAgo(a.last_accessed_at),
|
))
|
||||||
width: '120px',
|
)}
|
||||||
},
|
</tbody>
|
||||||
{
|
</table>
|
||||||
key: 'hits',
|
</div>
|
||||||
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>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user