|
|
@@ -1,8 +1,270 @@
|
|
|
+import { useEffect } from 'react'
|
|
|
+import { useQuery } from '@tanstack/react-query'
|
|
|
+import {
|
|
|
+ FolderOpen,
|
|
|
+ Layers,
|
|
|
+ FileText,
|
|
|
+ Database,
|
|
|
+ Gauge,
|
|
|
+ LayoutGrid,
|
|
|
+} from 'lucide-react'
|
|
|
+import { api } from '@/api/client'
|
|
|
+import { useAuthStore } from '@/stores/authStore'
|
|
|
+import { useToastStore } from '@/stores/toastStore'
|
|
|
+import type { ProjectStats, GlobalStats } from '@/types'
|
|
|
+
|
|
|
+// ─── helpers ─────────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+function humanizeBytes(bytes: number): string {
|
|
|
+ if (bytes < 1024) return `${bytes} B`
|
|
|
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
|
+ if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
|
+ return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`
|
|
|
+}
|
|
|
+
|
|
|
+type PressureLevel = 'normal' | 'soft' | 'hard' | 'emergency'
|
|
|
+
|
|
|
+function pressureColor(level: string): string {
|
|
|
+ const map: Record<PressureLevel, string> = {
|
|
|
+ normal: 'text-green-400',
|
|
|
+ soft: 'text-yellow-400',
|
|
|
+ hard: 'text-orange-400',
|
|
|
+ emergency: 'text-red-400',
|
|
|
+ }
|
|
|
+ return map[level as PressureLevel] ?? 'text-slate-300'
|
|
|
+}
|
|
|
+
|
|
|
+function pressureBg(level: string): string {
|
|
|
+ const map: Record<PressureLevel, string> = {
|
|
|
+ normal: 'bg-green-400/10 border-green-400/20',
|
|
|
+ soft: 'bg-yellow-400/10 border-yellow-400/20',
|
|
|
+ hard: 'bg-orange-400/10 border-orange-400/20',
|
|
|
+ emergency: 'bg-red-400/10 border-red-400/20',
|
|
|
+ }
|
|
|
+ return map[level as PressureLevel] ?? 'bg-slate-700/30 border-slate-600/30'
|
|
|
+}
|
|
|
+
|
|
|
+// ─── sub-components ───────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+interface StatCardProps {
|
|
|
+ icon: React.ReactNode
|
|
|
+ label: string
|
|
|
+ value: React.ReactNode
|
|
|
+ sub?: string
|
|
|
+ extra?: string
|
|
|
+}
|
|
|
+
|
|
|
+function StatCard({ icon, label, value, sub, extra }: StatCardProps) {
|
|
|
+ return (
|
|
|
+ <div className={`rounded-xl border bg-slate-800/60 border-slate-700/40 p-5 flex flex-col gap-3 ${extra ?? ''}`}>
|
|
|
+ <div className="flex items-center gap-2 text-slate-400 text-sm font-medium">
|
|
|
+ <span className="w-4 h-4">{icon}</span>
|
|
|
+ {label}
|
|
|
+ </div>
|
|
|
+ <div className="text-2xl font-bold text-slate-100 leading-none">{value}</div>
|
|
|
+ {sub && <div className="text-xs text-slate-500">{sub}</div>}
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+function SkeletonCard() {
|
|
|
+ return (
|
|
|
+ <div className="rounded-xl border border-slate-700/40 bg-slate-800/40 p-5 flex flex-col gap-3 animate-pulse">
|
|
|
+ <div className="h-4 w-24 rounded bg-slate-700/60" />
|
|
|
+ <div className="h-7 w-16 rounded bg-slate-700/40" />
|
|
|
+ <div className="h-3 w-20 rounded bg-slate-700/30" />
|
|
|
+ </div>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+// ─── project stats row ────────────────────────────────────────────────────────
|
|
|
+
|
|
|
+interface ProjectSectionProps {
|
|
|
+ currentProject: string
|
|
|
+}
|
|
|
+
|
|
|
+function ProjectSection({ currentProject }: ProjectSectionProps) {
|
|
|
+ const push = useToastStore((s) => s.push)
|
|
|
+
|
|
|
+ const { data, isLoading, isError, error } = useQuery<ProjectStats>({
|
|
|
+ queryKey: ['projectStats', currentProject],
|
|
|
+ queryFn: () => api.projectStats(currentProject),
|
|
|
+ enabled: true,
|
|
|
+ })
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (isError && error instanceof Error) {
|
|
|
+ push(`Project stats error: ${error.message}`, 'error')
|
|
|
+ }
|
|
|
+ }, [isError, error, push])
|
|
|
+
|
|
|
+ if (isLoading) {
|
|
|
+ return (
|
|
|
+ <section>
|
|
|
+ <h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider mb-3">
|
|
|
+ Project — {currentProject}
|
|
|
+ </h2>
|
|
|
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
|
+ <SkeletonCard />
|
|
|
+ <SkeletonCard />
|
|
|
+ <SkeletonCard />
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ if (isError || !data) {
|
|
|
+ return (
|
|
|
+ <section>
|
|
|
+ <h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider mb-3">
|
|
|
+ Project — {currentProject}
|
|
|
+ </h2>
|
|
|
+ <p className="text-red-400 text-sm">Failed to load project stats.</p>
|
|
|
+ </section>
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ return (
|
|
|
+ <section>
|
|
|
+ <h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider mb-3">
|
|
|
+ Project — {data.project}
|
|
|
+ </h2>
|
|
|
+ <div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
|
+ <StatCard
|
|
|
+ icon={<FolderOpen size={16} />}
|
|
|
+ label="Project"
|
|
|
+ value={data.project}
|
|
|
+ sub="current project"
|
|
|
+ />
|
|
|
+ <StatCard
|
|
|
+ icon={<Layers size={16} />}
|
|
|
+ label="Collections"
|
|
|
+ value={data.collections}
|
|
|
+ sub="in this project"
|
|
|
+ />
|
|
|
+ <StatCard
|
|
|
+ icon={<FileText size={16} />}
|
|
|
+ label="Documents"
|
|
|
+ value={data.documents.toLocaleString()}
|
|
|
+ sub="across all collections"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+// ─── global stats row (admin only) ───────────────────────────────────────────
|
|
|
+
|
|
|
+function GlobalSection() {
|
|
|
+ const push = useToastStore((s) => s.push)
|
|
|
+
|
|
|
+ const { data, isLoading, isError, error } = useQuery<GlobalStats>({
|
|
|
+ queryKey: ['globalStats'],
|
|
|
+ queryFn: api.globalStats,
|
|
|
+ })
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ if (isError && error instanceof Error) {
|
|
|
+ push(`Global stats error: ${error.message}`, 'error')
|
|
|
+ }
|
|
|
+ }, [isError, error, push])
|
|
|
+
|
|
|
+ if (isLoading) {
|
|
|
+ return (
|
|
|
+ <section>
|
|
|
+ <h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider mb-3">
|
|
|
+ Global (admin)
|
|
|
+ </h2>
|
|
|
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
|
|
|
+ {Array.from({ length: 5 }).map((_, i) => <SkeletonCard key={i} />)}
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ if (isError || !data) {
|
|
|
+ return (
|
|
|
+ <section>
|
|
|
+ <h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider mb-3">
|
|
|
+ Global (admin)
|
|
|
+ </h2>
|
|
|
+ <p className="text-red-400 text-sm">Failed to load global stats.</p>
|
|
|
+ </section>
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ const level = data.memory_pressure_level
|
|
|
+
|
|
|
+ return (
|
|
|
+ <section>
|
|
|
+ <h2 className="text-sm font-semibold text-slate-400 uppercase tracking-wider mb-3">
|
|
|
+ Global (admin)
|
|
|
+ </h2>
|
|
|
+ <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-4">
|
|
|
+ <StatCard
|
|
|
+ icon={<FileText size={16} />}
|
|
|
+ label="Total Documents"
|
|
|
+ value={data.total_documents.toLocaleString()}
|
|
|
+ sub="across all projects"
|
|
|
+ />
|
|
|
+ <StatCard
|
|
|
+ icon={<Layers size={16} />}
|
|
|
+ label="Total Collections"
|
|
|
+ value={data.total_collections}
|
|
|
+ sub="across all projects"
|
|
|
+ />
|
|
|
+ <StatCard
|
|
|
+ icon={<Database size={16} />}
|
|
|
+ label="Memory Used"
|
|
|
+ value={humanizeBytes(data.memory_used_bytes)}
|
|
|
+ sub={`${data.memory_pressure_percent.toFixed(1)}% of limit`}
|
|
|
+ />
|
|
|
+ <div
|
|
|
+ className={`rounded-xl border p-5 flex flex-col gap-3 ${pressureBg(level)}`}
|
|
|
+ >
|
|
|
+ <div className="flex items-center gap-2 text-slate-400 text-sm font-medium">
|
|
|
+ <span className="w-4 h-4"><Gauge size={16} /></span>
|
|
|
+ Memory Pressure
|
|
|
+ </div>
|
|
|
+ <div className={`text-2xl font-bold leading-none capitalize ${pressureColor(level)}`}>
|
|
|
+ {level}
|
|
|
+ </div>
|
|
|
+ <div className="text-xs text-slate-500">{data.memory_pressure_percent.toFixed(1)}% utilized</div>
|
|
|
+ </div>
|
|
|
+ <StatCard
|
|
|
+ icon={<LayoutGrid size={16} />}
|
|
|
+ label="Projects"
|
|
|
+ value={data.projects}
|
|
|
+ sub="active projects"
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ )
|
|
|
+}
|
|
|
+
|
|
|
+// ─── page ─────────────────────────────────────────────────────────────────────
|
|
|
+
|
|
|
export default function Dashboard() {
|
|
|
+ const currentProject = useAuthStore((s) => s.currentProject)
|
|
|
+ const admin = useAuthStore((s) => s.admin)
|
|
|
+
|
|
|
return (
|
|
|
- <div>
|
|
|
- <h1 className="text-2xl font-semibold text-slate-100 mb-2">Dashboard</h1>
|
|
|
- <p className="text-slate-400">Statistics and overview — coming in W5.</p>
|
|
|
+ <div className="flex flex-col gap-8">
|
|
|
+ <div>
|
|
|
+ <h1 className="text-2xl font-semibold text-slate-100">Dashboard</h1>
|
|
|
+ <p className="text-slate-400 text-sm mt-1">Overview of your VectorAPI workspace.</p>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ {currentProject !== null
|
|
|
+ ? <ProjectSection currentProject={currentProject} />
|
|
|
+ : (
|
|
|
+ <p className="text-slate-400 text-sm">
|
|
|
+ No project selected — choose a project from the selector above.
|
|
|
+ </p>
|
|
|
+ )
|
|
|
+ }
|
|
|
+
|
|
|
+ {admin && <GlobalSection />}
|
|
|
</div>
|
|
|
)
|
|
|
}
|