Procházet zdrojové kódy

feat(webui): dashboard with project + global stats

Fszontagh před 2 měsíci
rodič
revize
1e0bfe8ba9
1 změnil soubory, kde provedl 265 přidání a 3 odebrání
  1. 265 3
      webui/src/pages/Dashboard.tsx

+ 265 - 3
webui/src/pages/Dashboard.tsx

@@ -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>
   )
 }