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
+79 -3
View File
@@ -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<OverviewStats | null>(null);
const [topRemotes, setTopRemotes] = useState<RemoteStatRow[]>([]);
const [topFilesByHits, setTopFilesByHits] = useState<FileStatRow[]>([]);
const [topFilesByBW, setTopFilesByBW] = useState<BandwidthStatRow[]>([]);
const [health, setHealth] = useState<HealthStatus | null>(null);
const [error, setError] = useState<string | null>(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"
/>
<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>
);
}