From e37e1892b2e43f26e1aa5e8d7ab6a77b09ba7b55 Mon Sep 17 00:00:00 2001 From: Mondo Diaz Date: Mon, 5 Jan 2026 15:02:30 -0600 Subject: [PATCH] Add Dashboard UI for storage and deduplication statistics New components: - Dashboard.tsx - main dashboard page with stats overview - Dashboard.css - responsive styling with dark theme Features: - Storage overview cards (total used, saved, ratio, percentage) - Artifact statistics cards - Deduplication effectiveness visualization with progress bars - Circular progress indicator for deduplication rate - Top referenced artifacts table Updated files: - api.ts - added getStats, getDeduplicationStats, getTimelineStats, getCrossProjectStats - types.ts - added Stats, DeduplicationStats, TimelineStats interfaces - App.tsx - added /dashboard route - Layout.tsx - added Dashboard navigation link --- frontend/src/App.tsx | 2 + frontend/src/api.ts | 30 ++ frontend/src/components/Layout.tsx | 9 + frontend/src/pages/Dashboard.css | 547 +++++++++++++++++++++++++++++ frontend/src/pages/Dashboard.tsx | 436 +++++++++++++++++++++++ frontend/src/types.ts | 58 +++ 6 files changed, 1082 insertions(+) create mode 100644 frontend/src/pages/Dashboard.css create mode 100644 frontend/src/pages/Dashboard.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 6cfe5e1..aa31ff4 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -3,12 +3,14 @@ import Layout from './components/Layout'; import Home from './pages/Home'; import ProjectPage from './pages/ProjectPage'; import PackagePage from './pages/PackagePage'; +import Dashboard from './pages/Dashboard'; function App() { return ( } /> + } /> } /> } /> diff --git a/frontend/src/api.ts b/frontend/src/api.ts index 3602f8b..3f5b0c7 100644 --- a/frontend/src/api.ts +++ b/frontend/src/api.ts @@ -13,6 +13,10 @@ import { ArtifactListParams, ProjectListParams, GlobalSearchResponse, + Stats, + DeduplicationStats, + TimelineStats, + CrossProjectStats, } from './types'; const API_BASE = '/api/v1'; @@ -156,3 +160,29 @@ export async function uploadArtifact(projectName: string, packageName: string, f export function getDownloadUrl(projectName: string, packageName: string, ref: string): string { return `${API_BASE}/project/${projectName}/${packageName}/+/${ref}`; } + +// Stats API +export async function getStats(): Promise { + const response = await fetch(`${API_BASE}/stats`); + return handleResponse(response); +} + +export async function getDeduplicationStats(): Promise { + const response = await fetch(`${API_BASE}/stats/deduplication`); + return handleResponse(response); +} + +export async function getTimelineStats( + period: 'day' | 'week' | 'month' = 'day', + fromDate?: string, + toDate?: string +): Promise { + const params = buildQueryString({ period, from_date: fromDate, to_date: toDate }); + const response = await fetch(`${API_BASE}/stats/timeline${params}`); + return handleResponse(response); +} + +export async function getCrossProjectStats(): Promise { + const response = await fetch(`${API_BASE}/stats/cross-project`); + return handleResponse(response); +} diff --git a/frontend/src/components/Layout.tsx b/frontend/src/components/Layout.tsx index dc2130e..09d8832 100644 --- a/frontend/src/components/Layout.tsx +++ b/frontend/src/components/Layout.tsx @@ -42,6 +42,15 @@ function Layout({ children }: LayoutProps) { Projects + + + + + + + + Dashboard + diff --git a/frontend/src/pages/Dashboard.css b/frontend/src/pages/Dashboard.css new file mode 100644 index 0000000..2828193 --- /dev/null +++ b/frontend/src/pages/Dashboard.css @@ -0,0 +1,547 @@ +.dashboard { + max-width: 1200px; + margin: 0 auto; +} + +.dashboard__header { + position: relative; + margin-bottom: 48px; + padding-bottom: 32px; + border-bottom: 1px solid var(--border-primary); + overflow: hidden; +} + +.dashboard__header-content { + position: relative; + z-index: 1; +} + +.dashboard__header h1 { + font-size: 2.5rem; + font-weight: 700; + color: var(--text-primary); + letter-spacing: -0.03em; + margin-bottom: 8px; + background: linear-gradient(135deg, var(--text-primary) 0%, var(--accent-primary) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.dashboard__subtitle { + font-size: 1rem; + color: var(--text-tertiary); + letter-spacing: -0.01em; +} + +.dashboard__header-accent { + position: absolute; + top: -100px; + right: -100px; + width: 400px; + height: 400px; + background: radial-gradient(circle, rgba(16, 185, 129, 0.08) 0%, transparent 70%); + pointer-events: none; +} + +.dashboard__section { + margin-bottom: 48px; +} + +.dashboard__section-title { + display: flex; + align-items: center; + gap: 12px; + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 20px; + letter-spacing: -0.01em; +} + +.dashboard__section-title svg { + color: var(--accent-primary); +} + +.dashboard__section-description { + color: var(--text-tertiary); + font-size: 0.875rem; + margin-bottom: 20px; + margin-top: -8px; +} + +.stat-grid { + display: grid; + gap: 16px; +} + +.stat-grid--4 { + grid-template-columns: repeat(4, 1fr); +} + +.stat-grid--3 { + grid-template-columns: repeat(3, 1fr); +} + +.stat-grid--2 { + grid-template-columns: repeat(2, 1fr); +} + +@media (max-width: 1024px) { + .stat-grid--4 { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 640px) { + .stat-grid--4, + .stat-grid--3, + .stat-grid--2 { + grid-template-columns: 1fr; + } +} + +.stat-card { + position: relative; + display: flex; + align-items: flex-start; + gap: 16px; + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + padding: 20px; + transition: all var(--transition-normal); + overflow: hidden; +} + +.stat-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 3px; + background: var(--border-secondary); + transition: background var(--transition-normal); +} + +.stat-card:hover { + border-color: var(--border-secondary); + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +.stat-card--success::before { + background: var(--accent-gradient); +} + +.stat-card--success { + background: linear-gradient(135deg, rgba(16, 185, 129, 0.03) 0%, transparent 50%); +} + +.stat-card--accent::before { + background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); +} + +.stat-card--accent { + background: linear-gradient(135deg, rgba(59, 130, 246, 0.03) 0%, transparent 50%); +} + +.stat-card__icon { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + border-radius: var(--radius-md); + background: var(--bg-tertiary); + color: var(--text-tertiary); + flex-shrink: 0; +} + +.stat-card--success .stat-card__icon { + background: rgba(16, 185, 129, 0.1); + color: var(--accent-primary); +} + +.stat-card--accent .stat-card__icon { + background: rgba(59, 130, 246, 0.1); + color: #3b82f6; +} + +.stat-card__content { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.stat-card__label { + font-size: 0.75rem; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--text-tertiary); +} + +.stat-card__value { + font-size: 1.75rem; + font-weight: 700; + color: var(--text-primary); + letter-spacing: -0.02em; + line-height: 1.2; + display: flex; + align-items: baseline; + gap: 8px; +} + +.stat-card__subvalue { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 2px; +} + +.stat-card__trend { + font-size: 0.875rem; + font-weight: 600; +} + +.stat-card__trend--up { + color: var(--success); +} + +.stat-card__trend--down { + color: var(--error); +} + +.progress-bar { + width: 100%; +} + +.progress-bar__header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; +} + +.progress-bar__label { + font-size: 0.8125rem; + color: var(--text-secondary); +} + +.progress-bar__percentage { + font-size: 0.8125rem; + font-weight: 600; + color: var(--text-primary); +} + +.progress-bar__track { + position: relative; + height: 8px; + background: var(--bg-tertiary); + border-radius: 100px; + overflow: hidden; +} + +.progress-bar__fill { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: var(--border-secondary); + border-radius: 100px; + transition: width 0.5s ease-out; +} + +.progress-bar__glow { + position: absolute; + top: 0; + left: 0; + height: 100%; + background: transparent; + border-radius: 100px; + transition: width 0.5s ease-out; +} + +.progress-bar--success .progress-bar__fill { + background: var(--accent-gradient); +} + +.progress-bar--success .progress-bar__glow { + box-shadow: 0 0 12px rgba(16, 185, 129, 0.4); +} + +.progress-bar--accent .progress-bar__fill { + background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); +} + +.effectiveness-grid { + display: grid; + grid-template-columns: 1.5fr 1fr; + gap: 16px; +} + +@media (max-width: 900px) { + .effectiveness-grid { + grid-template-columns: 1fr; + } +} + +.effectiveness-card { + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); + padding: 24px; +} + +.effectiveness-card h3 { + font-size: 0.875rem; + font-weight: 600; + color: var(--text-primary); + margin-bottom: 24px; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.storage-comparison { + display: flex; + flex-direction: column; + gap: 20px; + margin-bottom: 24px; +} + +.storage-bar__label { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + font-size: 0.8125rem; + color: var(--text-secondary); +} + +.storage-bar__value { + font-weight: 600; + color: var(--text-primary); + font-family: 'JetBrains Mono', 'Fira Code', monospace; +} + +.storage-savings { + display: flex; + align-items: center; + gap: 16px; + padding: 20px; + background: linear-gradient(135deg, rgba(16, 185, 129, 0.08) 0%, rgba(5, 150, 105, 0.04) 100%); + border: 1px solid rgba(16, 185, 129, 0.2); + border-radius: var(--radius-md); +} + +.storage-savings__icon { + display: flex; + align-items: center; + justify-content: center; + width: 56px; + height: 56px; + border-radius: 50%; + background: var(--accent-gradient); + color: white; + flex-shrink: 0; + box-shadow: 0 0 24px rgba(16, 185, 129, 0.3); +} + +.storage-savings__content { + display: flex; + flex-direction: column; +} + +.storage-savings__value { + font-size: 1.5rem; + font-weight: 700; + color: var(--accent-primary); + letter-spacing: -0.02em; +} + +.storage-savings__label { + font-size: 0.8125rem; + color: var(--text-tertiary); +} + +.dedup-rate { + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; +} + +.dedup-rate__circle { + position: relative; + width: 160px; + height: 160px; +} + +.dedup-rate__svg { + width: 100%; + height: 100%; + transform: rotate(0deg); +} + +.dedup-rate__value { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + align-items: baseline; + gap: 2px; +} + +.dedup-rate__number { + font-size: 2.5rem; + font-weight: 700; + color: var(--text-primary); + letter-spacing: -0.03em; +} + +.dedup-rate__symbol { + font-size: 1.25rem; + font-weight: 600; + color: var(--text-tertiary); +} + +.dedup-rate__details { + display: flex; + gap: 32px; +} + +.dedup-rate__detail { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +.dedup-rate__detail-value { + font-size: 1.25rem; + font-weight: 700; + color: var(--text-primary); +} + +.dedup-rate__detail-label { + font-size: 0.6875rem; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-top: 4px; +} + +.artifacts-table { + margin-top: 16px; +} + +.artifact-link { + display: inline-block; +} + +.artifact-link code { + font-family: 'JetBrains Mono', 'Fira Code', monospace; + font-size: 0.8125rem; + padding: 4px 8px; + background: var(--bg-tertiary); + border-radius: var(--radius-sm); + color: var(--accent-primary); + transition: all var(--transition-fast); +} + +.artifact-link:hover code { + background: rgba(16, 185, 129, 0.15); +} + +.artifact-name { + max-width: 200px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + display: block; + color: var(--text-secondary); +} + +.ref-count { + display: inline-flex; + align-items: baseline; + gap: 4px; +} + +.ref-count__value { + font-weight: 600; + color: var(--text-primary); + font-size: 1rem; +} + +.ref-count__label { + font-size: 0.6875rem; + color: var(--text-muted); + text-transform: uppercase; +} + +.storage-saved { + color: var(--success); + font-weight: 600; +} + +.dashboard__loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + padding: 80px 32px; + color: var(--text-tertiary); +} + +.dashboard__loading-spinner { + width: 40px; + height: 40px; + border: 3px solid var(--border-primary); + border-top-color: var(--accent-primary); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +.dashboard__error { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 16px; + padding: 80px 32px; + text-align: center; + background: var(--bg-secondary); + border: 1px solid var(--border-primary); + border-radius: var(--radius-lg); +} + +.dashboard__error svg { + color: var(--error); + opacity: 0.5; +} + +.dashboard__error h3 { + font-size: 1.25rem; + font-weight: 600; + color: var(--text-primary); +} + +.dashboard__error p { + color: var(--text-tertiary); + max-width: 400px; +} + +.dashboard__error .btn { + margin-top: 8px; +} diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx new file mode 100644 index 0000000..3fbbafb --- /dev/null +++ b/frontend/src/pages/Dashboard.tsx @@ -0,0 +1,436 @@ +import { useState, useEffect } from 'react'; +import { Link } from 'react-router-dom'; +import { Stats, DeduplicationStats, ReferencedArtifact } from '../types'; +import { getStats, getDeduplicationStats } from '../api'; +import { DataTable } from '../components/DataTable'; +import './Dashboard.css'; + +function formatBytes(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; +} + +function formatNumber(num: number): string { + return num.toLocaleString(); +} + +function truncateHash(hash: string, length: number = 12): string { + if (hash.length <= length) return hash; + return `${hash.slice(0, length)}...`; +} + +interface StatCardProps { + label: string; + value: string; + subvalue?: string; + icon: React.ReactNode; + variant?: 'default' | 'success' | 'accent'; + trend?: 'up' | 'down' | 'neutral'; +} + +function StatCard({ label, value, subvalue, icon, variant = 'default', trend }: StatCardProps) { + return ( +
+
{icon}
+
+ {label} + + {value} + {trend && ( + + {trend === 'up' && '↑'} + {trend === 'down' && '↓'} + + )} + + {subvalue && {subvalue}} +
+
+ ); +} + +interface ProgressBarProps { + value: number; + max: number; + label?: string; + showPercentage?: boolean; + variant?: 'default' | 'success' | 'accent'; +} + +function ProgressBar({ value, max, label, showPercentage = true, variant = 'default' }: ProgressBarProps) { + const percentage = max > 0 ? Math.min((value / max) * 100, 100) : 0; + + return ( +
+ {label && ( +
+ {label} + {showPercentage && {percentage.toFixed(1)}%} +
+ )} +
+
+
+
+
+ ); +} + +function Dashboard() { + const [stats, setStats] = useState(null); + const [dedupStats, setDedupStats] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + async function loadStats() { + try { + setLoading(true); + const [statsData, dedupData] = await Promise.all([ + getStats(), + getDeduplicationStats(), + ]); + setStats(statsData); + setDedupStats(dedupData); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load statistics'); + } finally { + setLoading(false); + } + } + loadStats(); + }, []); + + if (loading) { + return ( +
+
+
+ Loading statistics... +
+
+ ); + } + + if (error) { + return ( +
+
+ + + + + +

Unable to load dashboard

+

{error}

+ +
+
+ ); + } + + const artifactColumns = [ + { + key: 'artifact_id', + header: 'Artifact ID', + render: (item: ReferencedArtifact) => ( + + {truncateHash(item.artifact_id, 16)} + + ), + }, + { + key: 'original_name', + header: 'Name', + render: (item: ReferencedArtifact) => ( + + {item.original_name || '—'} + + ), + }, + { + key: 'size', + header: 'Size', + render: (item: ReferencedArtifact) => formatBytes(item.size), + }, + { + key: 'ref_count', + header: 'References', + render: (item: ReferencedArtifact) => ( + + {formatNumber(item.ref_count)} + refs + + ), + }, + { + key: 'storage_saved', + header: 'Storage Saved', + render: (item: ReferencedArtifact) => ( + + {formatBytes(item.storage_saved)} + + ), + }, + ]; + + return ( +
+
+
+

Storage Dashboard

+

Real-time deduplication and storage analytics

+
+
+
+ +
+

+ + + + Storage Overview +

+
+ + + + } + variant="default" + /> + + + + + } + variant="success" + /> + + + + + + } + variant="accent" + /> + + + + + } + variant="success" + /> +
+
+ +
+

+ + + + + + Artifact Statistics +

+
+ + + + + } + /> + + + + + + } + /> + + + + + } + variant="success" + /> + + + + } + /> +
+
+ +
+

+ + + + + + Deduplication Effectiveness +

+
+
+

Logical vs Physical Storage

+
+
+
+ Logical (with duplicates) + {formatBytes(dedupStats?.total_logical_bytes || 0)} +
+ +
+
+
+ Physical (actual storage) + {formatBytes(dedupStats?.total_physical_bytes || 0)} +
+ +
+
+
+
+ + + +
+
+ {formatBytes(dedupStats?.bytes_saved || 0)} + saved through deduplication +
+
+
+ +
+

Deduplication Rate

+
+
+ + + + + + + + + + +
+ {(dedupStats?.savings_percentage || 0).toFixed(1)} + % +
+
+
+
+ {(stats?.deduplication_ratio || 1).toFixed(2)}x + Compression Ratio +
+
+ {formatNumber(stats?.deduplicated_uploads || 0)} + Duplicate Uploads +
+
+
+
+
+
+ + {dedupStats && dedupStats.most_referenced_artifacts.length > 0 && ( +
+

+ + + + Top Referenced Artifacts +

+

+ These artifacts are referenced most frequently across your storage, maximizing deduplication savings. +

+ item.artifact_id} + emptyMessage="No referenced artifacts found" + className="artifacts-table" + /> +
+ )} +
+ ); +} + +export default Dashboard; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 5d1d328..159fb21 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -161,3 +161,61 @@ export interface GlobalSearchResponse { export interface ProjectListParams extends ListParams { visibility?: 'public' | 'private'; } + +// Stats types +export interface Stats { + total_artifacts: number; + total_size_bytes: number; + unique_artifacts: number; + orphaned_artifacts: number; + total_uploads: number; + deduplicated_uploads: number; + deduplication_ratio: number; + storage_saved_bytes: number; +} + +export interface ReferencedArtifact { + artifact_id: string; + ref_count: number; + size: number; + original_name: string | null; + content_type: string | null; + storage_saved: number; +} + +export interface DeduplicationStats { + total_logical_bytes: number; + total_physical_bytes: number; + bytes_saved: number; + savings_percentage: number; + most_referenced_artifacts: ReferencedArtifact[]; +} + +export interface TimelineDataPoint { + date: string; + uploads: number; + deduplicated: number; + bytes_uploaded: number; + bytes_saved: number; +} + +export interface TimelineStats { + period: 'day' | 'week' | 'month'; + start_date: string; + end_date: string; + data_points: TimelineDataPoint[]; +} + +export interface CrossProjectDuplicate { + artifact_id: string; + size: number; + original_name: string | null; + projects: string[]; + total_references: number; +} + +export interface CrossProjectStats { + total_cross_project_duplicates: number; + bytes_saved_cross_project: number; + duplicates: CrossProjectDuplicate[]; +}