Add more tests
This commit is contained in:
25
frontend/src/api/endpoints/dashboard.ts
Normal file
25
frontend/src/api/endpoints/dashboard.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import apiClient from '../client'
|
||||
import type {
|
||||
DashboardStatsResponse,
|
||||
DashboardActiveModelResponse,
|
||||
RecentActivityResponse,
|
||||
} from '../types'
|
||||
|
||||
export const dashboardApi = {
|
||||
getStats: async (): Promise<DashboardStatsResponse> => {
|
||||
const response = await apiClient.get('/api/v1/admin/dashboard/stats')
|
||||
return response.data
|
||||
},
|
||||
|
||||
getActiveModel: async (): Promise<DashboardActiveModelResponse> => {
|
||||
const response = await apiClient.get('/api/v1/admin/dashboard/active-model')
|
||||
return response.data
|
||||
},
|
||||
|
||||
getRecentActivity: async (limit: number = 10): Promise<RecentActivityResponse> => {
|
||||
const response = await apiClient.get('/api/v1/admin/dashboard/activity', {
|
||||
params: { limit },
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
}
|
||||
@@ -5,3 +5,4 @@ export { inferenceApi } from './inference'
|
||||
export { datasetsApi } from './datasets'
|
||||
export { augmentationApi } from './augmentation'
|
||||
export { modelsApi } from './models'
|
||||
export { dashboardApi } from './dashboard'
|
||||
|
||||
@@ -362,3 +362,48 @@ export interface ActiveModelResponse {
|
||||
has_active_model: boolean
|
||||
model: ModelVersionItem | null
|
||||
}
|
||||
|
||||
// Dashboard types
|
||||
|
||||
export interface DashboardStatsResponse {
|
||||
total_documents: number
|
||||
annotation_complete: number
|
||||
annotation_incomplete: number
|
||||
pending: number
|
||||
completeness_rate: number
|
||||
}
|
||||
|
||||
export interface DashboardActiveModelInfo {
|
||||
version_id: string
|
||||
version: string
|
||||
name: string
|
||||
metrics_mAP: number | null
|
||||
metrics_precision: number | null
|
||||
metrics_recall: number | null
|
||||
document_count: number
|
||||
activated_at: string | null
|
||||
}
|
||||
|
||||
export interface DashboardRunningTrainingInfo {
|
||||
task_id: string
|
||||
name: string
|
||||
status: string
|
||||
started_at: string | null
|
||||
progress: number
|
||||
}
|
||||
|
||||
export interface DashboardActiveModelResponse {
|
||||
model: DashboardActiveModelInfo | null
|
||||
running_training: DashboardRunningTrainingInfo | null
|
||||
}
|
||||
|
||||
export interface ActivityItem {
|
||||
type: 'document_uploaded' | 'annotation_modified' | 'training_completed' | 'training_failed' | 'model_activated'
|
||||
description: string
|
||||
timestamp: string
|
||||
metadata: Record<string, unknown>
|
||||
}
|
||||
|
||||
export interface RecentActivityResponse {
|
||||
activities: ActivityItem[]
|
||||
}
|
||||
|
||||
@@ -1,47 +1,58 @@
|
||||
import React from 'react'
|
||||
import { FileText, CheckCircle, Clock, TrendingUp, Activity } from 'lucide-react'
|
||||
import { Button } from './Button'
|
||||
import { useDocuments } from '../hooks/useDocuments'
|
||||
import { useTraining } from '../hooks/useTraining'
|
||||
import { FileText, CheckCircle, AlertCircle, Clock, RefreshCw } from 'lucide-react'
|
||||
import {
|
||||
StatsCard,
|
||||
DataQualityPanel,
|
||||
ActiveModelPanel,
|
||||
RecentActivityPanel,
|
||||
SystemStatusBar,
|
||||
} from './dashboard/index'
|
||||
import { useDashboard } from '../hooks/useDashboard'
|
||||
|
||||
interface DashboardOverviewProps {
|
||||
onNavigate: (view: string) => void
|
||||
}
|
||||
|
||||
export const DashboardOverview: React.FC<DashboardOverviewProps> = ({ onNavigate }) => {
|
||||
const { total: totalDocs, isLoading: docsLoading } = useDocuments({ limit: 1 })
|
||||
const { models, isLoadingModels } = useTraining()
|
||||
const {
|
||||
stats,
|
||||
model,
|
||||
runningTraining,
|
||||
activities,
|
||||
isLoading,
|
||||
error,
|
||||
} = useDashboard()
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'Total Documents',
|
||||
value: docsLoading ? '...' : totalDocs.toString(),
|
||||
icon: FileText,
|
||||
color: 'text-warm-text-primary',
|
||||
bgColor: 'bg-warm-bg',
|
||||
},
|
||||
{
|
||||
label: 'Labeled',
|
||||
value: '0',
|
||||
icon: CheckCircle,
|
||||
color: 'text-warm-state-success',
|
||||
bgColor: 'bg-green-50',
|
||||
},
|
||||
{
|
||||
label: 'Pending',
|
||||
value: '0',
|
||||
icon: Clock,
|
||||
color: 'text-warm-state-warning',
|
||||
bgColor: 'bg-yellow-50',
|
||||
},
|
||||
{
|
||||
label: 'Training Models',
|
||||
value: isLoadingModels ? '...' : models.length.toString(),
|
||||
icon: TrendingUp,
|
||||
color: 'text-warm-state-info',
|
||||
bgColor: 'bg-blue-50',
|
||||
},
|
||||
]
|
||||
const handleStatsClick = (filter?: string) => {
|
||||
if (filter) {
|
||||
onNavigate(`documents?status=${filter}`)
|
||||
} else {
|
||||
onNavigate('documents')
|
||||
}
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="p-8 max-w-7xl mx-auto">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6 text-center">
|
||||
<AlertCircle className="w-12 h-12 text-red-500 mx-auto mb-4" />
|
||||
<h2 className="text-lg font-semibold text-red-800 mb-2">
|
||||
Failed to load dashboard
|
||||
</h2>
|
||||
<p className="text-sm text-red-600 mb-4">
|
||||
{error instanceof Error ? error.message : 'An unexpected error occurred'}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-red-100 hover:bg-red-200 text-red-800 rounded-md text-sm font-medium transition-colors"
|
||||
>
|
||||
<RefreshCw className="w-4 h-4" />
|
||||
Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 max-w-7xl mx-auto animate-fade-in">
|
||||
@@ -55,94 +66,74 @@ export const DashboardOverview: React.FC<DashboardOverviewProps> = ({ onNavigate
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats Grid */}
|
||||
{/* Stats Cards Row */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
|
||||
{stats.map((stat) => (
|
||||
<div
|
||||
key={stat.label}
|
||||
className="bg-warm-card border border-warm-border rounded-lg p-6 shadow-sm hover:shadow-md transition-shadow"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className={`p-3 rounded-lg ${stat.bgColor}`}>
|
||||
<stat.icon className={stat.color} size={24} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-warm-text-primary mb-1">
|
||||
{stat.value}
|
||||
</p>
|
||||
<p className="text-sm text-warm-text-muted">{stat.label}</p>
|
||||
</div>
|
||||
))}
|
||||
<StatsCard
|
||||
label="Total Documents"
|
||||
value={stats?.total_documents ?? 0}
|
||||
icon={FileText}
|
||||
iconColor="text-warm-text-primary"
|
||||
iconBgColor="bg-warm-bg"
|
||||
isLoading={isLoading}
|
||||
onClick={() => handleStatsClick()}
|
||||
/>
|
||||
<StatsCard
|
||||
label="Complete"
|
||||
value={stats?.annotation_complete ?? 0}
|
||||
icon={CheckCircle}
|
||||
iconColor="text-warm-state-success"
|
||||
iconBgColor="bg-green-50"
|
||||
isLoading={isLoading}
|
||||
onClick={() => handleStatsClick('labeled')}
|
||||
/>
|
||||
<StatsCard
|
||||
label="Incomplete"
|
||||
value={stats?.annotation_incomplete ?? 0}
|
||||
icon={AlertCircle}
|
||||
iconColor="text-orange-600"
|
||||
iconBgColor="bg-orange-50"
|
||||
isLoading={isLoading}
|
||||
onClick={() => handleStatsClick('labeled')}
|
||||
/>
|
||||
<StatsCard
|
||||
label="Pending"
|
||||
value={stats?.pending ?? 0}
|
||||
icon={Clock}
|
||||
iconColor="text-blue-600"
|
||||
iconBgColor="bg-blue-50"
|
||||
isLoading={isLoading}
|
||||
onClick={() => handleStatsClick('pending')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="bg-warm-card border border-warm-border rounded-lg p-6 shadow-sm mb-8">
|
||||
<h2 className="text-lg font-semibold text-warm-text-primary mb-4">
|
||||
Quick Actions
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Button onClick={() => onNavigate('documents')} className="justify-start">
|
||||
<FileText size={18} className="mr-2" />
|
||||
Manage Documents
|
||||
</Button>
|
||||
<Button onClick={() => onNavigate('training')} variant="secondary" className="justify-start">
|
||||
<Activity size={18} className="mr-2" />
|
||||
Start Training
|
||||
</Button>
|
||||
<Button onClick={() => onNavigate('models')} variant="secondary" className="justify-start">
|
||||
<TrendingUp size={18} className="mr-2" />
|
||||
View Models
|
||||
</Button>
|
||||
</div>
|
||||
{/* Two-column layout: Data Quality + Active Model */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-8">
|
||||
<DataQualityPanel
|
||||
completenessRate={stats?.completeness_rate ?? 0}
|
||||
completeCount={stats?.annotation_complete ?? 0}
|
||||
incompleteCount={stats?.annotation_incomplete ?? 0}
|
||||
pendingCount={stats?.pending ?? 0}
|
||||
isLoading={isLoading}
|
||||
onViewIncomplete={() => handleStatsClick('labeled')}
|
||||
/>
|
||||
<ActiveModelPanel
|
||||
model={model}
|
||||
runningTraining={runningTraining}
|
||||
isLoading={isLoading}
|
||||
onGoToTraining={() => onNavigate('training')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Recent Activity */}
|
||||
<div className="bg-warm-card border border-warm-border rounded-lg shadow-sm overflow-hidden">
|
||||
<div className="p-6 border-b border-warm-border">
|
||||
<h2 className="text-lg font-semibold text-warm-text-primary">
|
||||
Recent Activity
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="text-center py-8 text-warm-text-muted">
|
||||
<Activity size={48} className="mx-auto mb-3 opacity-20" />
|
||||
<p className="text-sm">No recent activity</p>
|
||||
<p className="text-xs mt-1">
|
||||
Start by uploading documents or creating training jobs
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-8">
|
||||
<RecentActivityPanel
|
||||
activities={activities}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* System Status */}
|
||||
<div className="mt-8 bg-warm-card border border-warm-border rounded-lg p-6 shadow-sm">
|
||||
<h2 className="text-lg font-semibold text-warm-text-primary mb-4">
|
||||
System Status
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-warm-text-secondary">Backend API</span>
|
||||
<span className="flex items-center text-sm text-warm-state-success">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full mr-2"></span>
|
||||
Online
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-warm-text-secondary">Database</span>
|
||||
<span className="flex items-center text-sm text-warm-state-success">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full mr-2"></span>
|
||||
Connected
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-warm-text-secondary">GPU</span>
|
||||
<span className="flex items-center text-sm text-warm-state-success">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full mr-2"></span>
|
||||
Available
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<SystemStatusBar />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
143
frontend/src/components/dashboard/ActiveModelPanel.tsx
Normal file
143
frontend/src/components/dashboard/ActiveModelPanel.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import React from 'react'
|
||||
import { TrendingUp } from 'lucide-react'
|
||||
import { Button } from '../Button'
|
||||
import type { DashboardActiveModelInfo, DashboardRunningTrainingInfo } from '../../api/types'
|
||||
|
||||
interface ActiveModelPanelProps {
|
||||
model: DashboardActiveModelInfo | null
|
||||
runningTraining: DashboardRunningTrainingInfo | null
|
||||
isLoading?: boolean
|
||||
onGoToTraining?: () => void
|
||||
}
|
||||
|
||||
const formatDate = (dateStr: string | null): string => {
|
||||
if (!dateStr) return 'N/A'
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
const formatMetric = (value: number | null): string => {
|
||||
if (value === null) return 'N/A'
|
||||
return `${(value * 100).toFixed(1)}%`
|
||||
}
|
||||
|
||||
const getMetricColor = (value: number | null): string => {
|
||||
if (value === null) return 'text-warm-text-muted'
|
||||
if (value >= 0.9) return 'text-green-600'
|
||||
if (value >= 0.8) return 'text-yellow-600'
|
||||
return 'text-red-600'
|
||||
}
|
||||
|
||||
export const ActiveModelPanel: React.FC<ActiveModelPanelProps> = ({
|
||||
model,
|
||||
runningTraining,
|
||||
isLoading = false,
|
||||
onGoToTraining,
|
||||
}) => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-warm-card border border-warm-border rounded-lg p-6 shadow-sm">
|
||||
<h2 className="text-sm font-semibold text-warm-text-muted uppercase tracking-wide mb-4">
|
||||
Active Model
|
||||
</h2>
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-pulse text-warm-text-muted">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!model) {
|
||||
return (
|
||||
<div className="bg-warm-card border border-warm-border rounded-lg p-6 shadow-sm">
|
||||
<h2 className="text-sm font-semibold text-warm-text-muted uppercase tracking-wide mb-4">
|
||||
Active Model
|
||||
</h2>
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<TrendingUp className="w-12 h-12 text-warm-text-disabled mb-3 opacity-20" />
|
||||
<p className="text-warm-text-primary font-medium mb-1">No Active Model</p>
|
||||
<p className="text-sm text-warm-text-muted mb-4">
|
||||
Train and activate a model to see stats here
|
||||
</p>
|
||||
{onGoToTraining && (
|
||||
<Button onClick={onGoToTraining} variant="primary" size="sm">
|
||||
Go to Training
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-warm-card border border-warm-border rounded-lg p-6 shadow-sm">
|
||||
<h2 className="text-sm font-semibold text-warm-text-muted uppercase tracking-wide mb-4">
|
||||
Active Model
|
||||
</h2>
|
||||
|
||||
<div className="mb-4">
|
||||
<span className="text-lg font-bold text-warm-text-primary">{model.version}</span>
|
||||
<span className="text-warm-text-secondary ml-2">- {model.name}</span>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-warm-border pt-4 mb-4">
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="text-center">
|
||||
<p className={`text-2xl font-bold ${getMetricColor(model.metrics_mAP)}`}>
|
||||
{formatMetric(model.metrics_mAP)}
|
||||
</p>
|
||||
<p className="text-xs text-warm-text-muted uppercase">mAP</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className={`text-2xl font-bold ${getMetricColor(model.metrics_precision)}`}>
|
||||
{formatMetric(model.metrics_precision)}
|
||||
</p>
|
||||
<p className="text-xs text-warm-text-muted uppercase">Precision</p>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className={`text-2xl font-bold ${getMetricColor(model.metrics_recall)}`}>
|
||||
{formatMetric(model.metrics_recall)}
|
||||
</p>
|
||||
<p className="text-xs text-warm-text-muted uppercase">Recall</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1 text-sm text-warm-text-secondary">
|
||||
<p>
|
||||
<span className="text-warm-text-muted">Activated:</span>{' '}
|
||||
{formatDate(model.activated_at)}
|
||||
</p>
|
||||
<p>
|
||||
<span className="text-warm-text-muted">Documents:</span>{' '}
|
||||
{model.document_count.toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{runningTraining && (
|
||||
<div className="mt-4 p-3 bg-blue-50 rounded-lg border border-blue-100">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<span className="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
|
||||
<span className="text-sm font-medium text-warm-text-primary">
|
||||
Training in Progress
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-warm-text-secondary mb-2">{runningTraining.name}</p>
|
||||
<div className="w-full bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-blue-500 h-2 rounded-full transition-all duration-500"
|
||||
style={{ width: `${runningTraining.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-warm-text-muted mt-1">
|
||||
{runningTraining.progress}% complete
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
105
frontend/src/components/dashboard/DataQualityPanel.tsx
Normal file
105
frontend/src/components/dashboard/DataQualityPanel.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
import React from 'react'
|
||||
import { Button } from '../Button'
|
||||
|
||||
interface DataQualityPanelProps {
|
||||
completenessRate: number
|
||||
completeCount: number
|
||||
incompleteCount: number
|
||||
pendingCount: number
|
||||
isLoading?: boolean
|
||||
onViewIncomplete?: () => void
|
||||
}
|
||||
|
||||
export const DataQualityPanel: React.FC<DataQualityPanelProps> = ({
|
||||
completenessRate,
|
||||
completeCount,
|
||||
incompleteCount,
|
||||
pendingCount,
|
||||
isLoading = false,
|
||||
onViewIncomplete,
|
||||
}) => {
|
||||
const radius = 54
|
||||
const circumference = 2 * Math.PI * radius
|
||||
const strokeDashoffset = circumference - (completenessRate / 100) * circumference
|
||||
|
||||
return (
|
||||
<div className="bg-warm-card border border-warm-border rounded-lg p-6 shadow-sm">
|
||||
<h2 className="text-sm font-semibold text-warm-text-muted uppercase tracking-wide mb-4">
|
||||
Data Quality
|
||||
</h2>
|
||||
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="relative">
|
||||
<svg width="120" height="120" className="transform -rotate-90">
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r={radius}
|
||||
stroke="#E5E7EB"
|
||||
strokeWidth="12"
|
||||
fill="none"
|
||||
/>
|
||||
<circle
|
||||
cx="60"
|
||||
cy="60"
|
||||
r={radius}
|
||||
stroke="#22C55E"
|
||||
strokeWidth="12"
|
||||
fill="none"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={isLoading ? circumference : strokeDashoffset}
|
||||
className="transition-all duration-500"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-3xl font-bold text-warm-text-primary">
|
||||
{isLoading ? '...' : `${Math.round(completenessRate)}%`}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-warm-text-secondary mb-4">
|
||||
Annotation Complete
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-green-500 rounded-full" />
|
||||
Complete
|
||||
</span>
|
||||
<span className="font-medium">{isLoading ? '...' : completeCount}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-orange-500 rounded-full" />
|
||||
Incomplete
|
||||
</span>
|
||||
<span className="font-medium">{isLoading ? '...' : incompleteCount}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="flex items-center gap-2">
|
||||
<span className="w-2 h-2 bg-blue-500 rounded-full" />
|
||||
Pending
|
||||
</span>
|
||||
<span className="font-medium">{isLoading ? '...' : pendingCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{onViewIncomplete && incompleteCount > 0 && (
|
||||
<div className="mt-4 pt-4 border-t border-warm-border">
|
||||
<button
|
||||
onClick={onViewIncomplete}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 font-medium"
|
||||
>
|
||||
View Incomplete Docs
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
134
frontend/src/components/dashboard/RecentActivityPanel.tsx
Normal file
134
frontend/src/components/dashboard/RecentActivityPanel.tsx
Normal file
@@ -0,0 +1,134 @@
|
||||
import React from 'react'
|
||||
import {
|
||||
FileText,
|
||||
Edit,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Rocket,
|
||||
Activity,
|
||||
} from 'lucide-react'
|
||||
import type { ActivityItem } from '../../api/types'
|
||||
|
||||
interface RecentActivityPanelProps {
|
||||
activities: ActivityItem[]
|
||||
isLoading?: boolean
|
||||
onSeeAll?: () => void
|
||||
}
|
||||
|
||||
const getActivityIcon = (type: ActivityItem['type']) => {
|
||||
switch (type) {
|
||||
case 'document_uploaded':
|
||||
return { Icon: FileText, color: 'text-blue-500', bg: 'bg-blue-50' }
|
||||
case 'annotation_modified':
|
||||
return { Icon: Edit, color: 'text-orange-500', bg: 'bg-orange-50' }
|
||||
case 'training_completed':
|
||||
return { Icon: CheckCircle, color: 'text-green-500', bg: 'bg-green-50' }
|
||||
case 'training_failed':
|
||||
return { Icon: XCircle, color: 'text-red-500', bg: 'bg-red-50' }
|
||||
case 'model_activated':
|
||||
return { Icon: Rocket, color: 'text-purple-500', bg: 'bg-purple-50' }
|
||||
default:
|
||||
return { Icon: Activity, color: 'text-gray-500', bg: 'bg-gray-50' }
|
||||
}
|
||||
}
|
||||
|
||||
const formatTimestamp = (timestamp: string): string => {
|
||||
const date = new Date(timestamp)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffMinutes = Math.floor(diffMs / 60000)
|
||||
const diffHours = Math.floor(diffMs / 3600000)
|
||||
const diffDays = Math.floor(diffMs / 86400000)
|
||||
|
||||
if (diffMinutes < 1) return 'just now'
|
||||
if (diffMinutes < 60) return `${diffMinutes} minutes ago`
|
||||
if (diffHours < 24) return `${diffHours} hours ago`
|
||||
if (diffDays === 1) return 'yesterday'
|
||||
if (diffDays < 7) return `${diffDays} days ago`
|
||||
|
||||
return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })
|
||||
}
|
||||
|
||||
export const RecentActivityPanel: React.FC<RecentActivityPanelProps> = ({
|
||||
activities,
|
||||
isLoading = false,
|
||||
onSeeAll,
|
||||
}) => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="bg-warm-card border border-warm-border rounded-lg shadow-sm overflow-hidden">
|
||||
<div className="p-6 border-b border-warm-border flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-warm-text-muted uppercase tracking-wide">
|
||||
Recent Activity
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<div className="animate-pulse text-warm-text-muted">Loading...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (activities.length === 0) {
|
||||
return (
|
||||
<div className="bg-warm-card border border-warm-border rounded-lg shadow-sm overflow-hidden">
|
||||
<div className="p-6 border-b border-warm-border">
|
||||
<h2 className="text-sm font-semibold text-warm-text-muted uppercase tracking-wide">
|
||||
Recent Activity
|
||||
</h2>
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<div className="flex flex-col items-center justify-center py-8 text-center">
|
||||
<Activity className="w-12 h-12 text-warm-text-disabled mb-3 opacity-20" />
|
||||
<p className="text-warm-text-primary font-medium mb-1">No recent activity</p>
|
||||
<p className="text-sm text-warm-text-muted">
|
||||
Start by uploading documents or creating training jobs
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-warm-card border border-warm-border rounded-lg shadow-sm overflow-hidden">
|
||||
<div className="p-6 border-b border-warm-border flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-warm-text-muted uppercase tracking-wide">
|
||||
Recent Activity
|
||||
</h2>
|
||||
{onSeeAll && (
|
||||
<button
|
||||
onClick={onSeeAll}
|
||||
className="text-sm text-blue-600 hover:text-blue-800 font-medium"
|
||||
>
|
||||
See All
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="divide-y divide-warm-border">
|
||||
{activities.map((activity, index) => {
|
||||
const { Icon, color, bg } = getActivityIcon(activity.type)
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${activity.type}-${activity.timestamp}-${index}`}
|
||||
className="px-6 py-3 flex items-center gap-4 hover:bg-warm-hover transition-colors"
|
||||
>
|
||||
<div className={`p-2 rounded-lg ${bg}`}>
|
||||
<Icon className={color} size={16} />
|
||||
</div>
|
||||
<p className="flex-1 text-sm text-warm-text-primary truncate">
|
||||
{activity.description}
|
||||
</p>
|
||||
<span className="text-xs text-warm-text-muted whitespace-nowrap">
|
||||
{formatTimestamp(activity.timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
44
frontend/src/components/dashboard/StatsCard.tsx
Normal file
44
frontend/src/components/dashboard/StatsCard.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React from 'react'
|
||||
import { LucideIcon } from 'lucide-react'
|
||||
|
||||
interface StatsCardProps {
|
||||
label: string
|
||||
value: string | number
|
||||
icon: LucideIcon
|
||||
iconColor: string
|
||||
iconBgColor: string
|
||||
onClick?: () => void
|
||||
isLoading?: boolean
|
||||
}
|
||||
|
||||
export const StatsCard: React.FC<StatsCardProps> = ({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
iconColor,
|
||||
iconBgColor,
|
||||
onClick,
|
||||
isLoading = false,
|
||||
}) => {
|
||||
return (
|
||||
<div
|
||||
className={`bg-warm-card border border-warm-border rounded-lg p-6 shadow-sm hover:shadow-md transition-shadow ${
|
||||
onClick ? 'cursor-pointer' : ''
|
||||
}`}
|
||||
onClick={onClick}
|
||||
role={onClick ? 'button' : undefined}
|
||||
tabIndex={onClick ? 0 : undefined}
|
||||
onKeyDown={onClick ? (e) => e.key === 'Enter' && onClick() : undefined}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className={`p-3 rounded-lg ${iconBgColor}`}>
|
||||
<Icon className={iconColor} size={24} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-2xl font-bold text-warm-text-primary mb-1">
|
||||
{isLoading ? '...' : value}
|
||||
</p>
|
||||
<p className="text-sm text-warm-text-muted">{label}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
62
frontend/src/components/dashboard/SystemStatusBar.tsx
Normal file
62
frontend/src/components/dashboard/SystemStatusBar.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React from 'react'
|
||||
|
||||
interface StatusItem {
|
||||
label: string
|
||||
status: 'online' | 'degraded' | 'offline'
|
||||
statusText: string
|
||||
}
|
||||
|
||||
interface SystemStatusBarProps {
|
||||
items?: StatusItem[]
|
||||
}
|
||||
|
||||
const getStatusColor = (status: StatusItem['status']) => {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return 'bg-green-500'
|
||||
case 'degraded':
|
||||
return 'bg-yellow-500'
|
||||
case 'offline':
|
||||
return 'bg-red-500'
|
||||
}
|
||||
}
|
||||
|
||||
const getStatusTextColor = (status: StatusItem['status']) => {
|
||||
switch (status) {
|
||||
case 'online':
|
||||
return 'text-warm-state-success'
|
||||
case 'degraded':
|
||||
return 'text-yellow-600'
|
||||
case 'offline':
|
||||
return 'text-red-600'
|
||||
}
|
||||
}
|
||||
|
||||
const defaultItems: StatusItem[] = [
|
||||
{ label: 'Backend API', status: 'online', statusText: 'Online' },
|
||||
{ label: 'Database', status: 'online', statusText: 'Connected' },
|
||||
{ label: 'GPU', status: 'online', statusText: 'Available' },
|
||||
]
|
||||
|
||||
export const SystemStatusBar: React.FC<SystemStatusBarProps> = ({
|
||||
items = defaultItems,
|
||||
}) => {
|
||||
return (
|
||||
<div className="bg-warm-card border border-warm-border rounded-lg p-6 shadow-sm">
|
||||
<h2 className="text-sm font-semibold text-warm-text-muted uppercase tracking-wide mb-4">
|
||||
System Status
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{items.map((item) => (
|
||||
<div key={item.label} className="flex items-center justify-between">
|
||||
<span className="text-sm text-warm-text-secondary">{item.label}</span>
|
||||
<span className={`flex items-center text-sm ${getStatusTextColor(item.status)}`}>
|
||||
<span className={`w-2 h-2 ${getStatusColor(item.status)} rounded-full mr-2`} />
|
||||
{item.statusText}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
5
frontend/src/components/dashboard/index.ts
Normal file
5
frontend/src/components/dashboard/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { StatsCard } from './StatsCard'
|
||||
export { DataQualityPanel } from './DataQualityPanel'
|
||||
export { ActiveModelPanel } from './ActiveModelPanel'
|
||||
export { RecentActivityPanel } from './RecentActivityPanel'
|
||||
export { SystemStatusBar } from './SystemStatusBar'
|
||||
@@ -5,3 +5,4 @@ export { useTraining, useTrainingDocuments } from './useTraining'
|
||||
export { useDatasets, useDatasetDetail } from './useDatasets'
|
||||
export { useAugmentation } from './useAugmentation'
|
||||
export { useModels, useModelDetail, useActiveModel } from './useModels'
|
||||
export { useDashboard, useDashboardStats, useActiveModel as useDashboardActiveModel, useRecentActivity } from './useDashboard'
|
||||
|
||||
76
frontend/src/hooks/useDashboard.ts
Normal file
76
frontend/src/hooks/useDashboard.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { dashboardApi } from '../api/endpoints'
|
||||
import type {
|
||||
DashboardStatsResponse,
|
||||
DashboardActiveModelResponse,
|
||||
RecentActivityResponse,
|
||||
} from '../api/types'
|
||||
|
||||
export const useDashboardStats = () => {
|
||||
const { data, isLoading, error, refetch } = useQuery<DashboardStatsResponse>({
|
||||
queryKey: ['dashboard', 'stats'],
|
||||
queryFn: () => dashboardApi.getStats(),
|
||||
staleTime: 30000,
|
||||
refetchInterval: 60000,
|
||||
})
|
||||
|
||||
return {
|
||||
stats: data,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
}
|
||||
}
|
||||
|
||||
export const useActiveModel = () => {
|
||||
const { data, isLoading, error, refetch } = useQuery<DashboardActiveModelResponse>({
|
||||
queryKey: ['dashboard', 'active-model'],
|
||||
queryFn: () => dashboardApi.getActiveModel(),
|
||||
staleTime: 30000,
|
||||
refetchInterval: 60000,
|
||||
})
|
||||
|
||||
return {
|
||||
model: data?.model ?? null,
|
||||
runningTraining: data?.running_training ?? null,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
}
|
||||
}
|
||||
|
||||
export const useRecentActivity = (limit: number = 10) => {
|
||||
const { data, isLoading, error, refetch } = useQuery<RecentActivityResponse>({
|
||||
queryKey: ['dashboard', 'activity', limit],
|
||||
queryFn: () => dashboardApi.getRecentActivity(limit),
|
||||
staleTime: 30000,
|
||||
refetchInterval: 60000,
|
||||
})
|
||||
|
||||
return {
|
||||
activities: data?.activities ?? [],
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
}
|
||||
}
|
||||
|
||||
export const useDashboard = () => {
|
||||
const stats = useDashboardStats()
|
||||
const activeModel = useActiveModel()
|
||||
const activity = useRecentActivity()
|
||||
|
||||
return {
|
||||
stats: stats.stats,
|
||||
model: activeModel.model,
|
||||
runningTraining: activeModel.runningTraining,
|
||||
activities: activity.activities,
|
||||
isLoading: stats.isLoading || activeModel.isLoading || activity.isLoading,
|
||||
error: stats.error || activeModel.error || activity.error,
|
||||
refetch: () => {
|
||||
stats.refetch()
|
||||
activeModel.refetch()
|
||||
activity.refetch()
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user