GT AI OS Community Edition v2.0.33
Security hardening release addressing CodeQL and Dependabot alerts: - Fix stack trace exposure in error responses - Add SSRF protection with DNS resolution checking - Implement proper URL hostname validation (replaces substring matching) - Add centralized path sanitization to prevent path traversal - Fix ReDoS vulnerability in email validation regex - Improve HTML sanitization in validation utilities - Fix capability wildcard matching in auth utilities - Update glob dependency to address CVE - Add CodeQL suppression comments for verified false positives 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
514
apps/tenant-app/src/app/agents-archived/agents/page.tsx
Normal file
514
apps/tenant-app/src/app/agents-archived/agents/page.tsx
Normal file
@@ -0,0 +1,514 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { AgentCard } from '@/components/agents/agent-card';
|
||||
import { AgentCreateModal } from '@/components/agents';
|
||||
import { AgentExecutionModal } from '@/components/agents/agent-execution-modal';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
Bot,
|
||||
Brain,
|
||||
Code,
|
||||
Activity,
|
||||
Clock,
|
||||
DollarSign,
|
||||
TrendingUp,
|
||||
Zap
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Agent {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
agent_type: 'research' | 'coding' | 'analysis' | 'custom';
|
||||
capabilities: string[];
|
||||
usage_count: number;
|
||||
last_used?: string;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface AgentExecution {
|
||||
id: string;
|
||||
agent_id: string;
|
||||
task_description: string;
|
||||
task_parameters: Record<string, any>;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
progress_percentage: number;
|
||||
current_step?: string;
|
||||
result_data: Record<string, any>;
|
||||
output_artifacts: string[];
|
||||
error_details?: string;
|
||||
execution_time_ms?: number;
|
||||
tokens_used: number;
|
||||
cost_cents: number;
|
||||
tool_calls_count: number;
|
||||
started_at?: string;
|
||||
completed_at?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
import { AppLayout } from '@/components/layout/app-layout';
|
||||
|
||||
function AgentsPageContent() {
|
||||
const [agents, setAgents] = useState<Agent[]>([]);
|
||||
const [executions, setExecutions] = useState<AgentExecution[]>([]);
|
||||
const [filteredAgents, setFilteredAgents] = useState<Agent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedType, setSelectedType] = useState<string>('all');
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showExecuteModal, setShowExecuteModal] = useState(false);
|
||||
const [selectedAgent, setSelectedAgent] = useState<Agent | null>(null);
|
||||
const [currentExecution, setCurrentExecution] = useState<AgentExecution | null>(null);
|
||||
|
||||
// Load real data from backend API
|
||||
useEffect(() => {
|
||||
const loadAgents = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Load agents from backend API - using standardized GT 2.0 token key
|
||||
const authToken = localStorage.getItem('gt2_token');
|
||||
const response = await fetch('http://localhost:8001/api/v1/agents', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const backendAgents = await response.json();
|
||||
|
||||
// Convert backend format to frontend format
|
||||
const convertedAgents: Agent[] = backendAgents.map((agent: any) => ({
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
description: agent.description,
|
||||
agent_type: agent.agent_type,
|
||||
capabilities: agent.capabilities || [],
|
||||
usage_count: agent.usage_count || 0,
|
||||
last_used: agent.last_used,
|
||||
is_active: agent.is_active,
|
||||
created_at: agent.created_at
|
||||
}));
|
||||
|
||||
setAgents(convertedAgents);
|
||||
setFilteredAgents(convertedAgents);
|
||||
|
||||
// Load recent executions
|
||||
// TODO: Add execution history endpoint to backend
|
||||
setExecutions([]);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load agents:', error);
|
||||
// Fallback to empty state instead of mock data
|
||||
setAgents([]);
|
||||
setFilteredAgents([]);
|
||||
setExecutions([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadAgents();
|
||||
}, []);
|
||||
|
||||
// Filter agents based on search and type
|
||||
useEffect(() => {
|
||||
let filtered = agents;
|
||||
|
||||
if (searchQuery) {
|
||||
filtered = filtered.filter(agent =>
|
||||
agent.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
agent.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
agent.capabilities.some(cap => cap.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedType !== 'all') {
|
||||
filtered = filtered.filter(agent => agent.agent_type === selectedType);
|
||||
}
|
||||
|
||||
setFilteredAgents(filtered);
|
||||
}, [agents, searchQuery, selectedType]);
|
||||
|
||||
const handleCreateAgent = async (agentData: any) => {
|
||||
try {
|
||||
const authToken = localStorage.getItem('gt2_token');
|
||||
const response = await fetch('http://localhost:8001/api/v1/agents', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: agentData.name,
|
||||
description: agentData.description,
|
||||
agent_type: agentData.agent_type,
|
||||
prompt_template: agentData.prompt_template || `You are a ${agentData.agent_type} agent focused on helping users with ${agentData.description || 'various tasks'}.`,
|
||||
capabilities: agentData.capabilities || [],
|
||||
model_preferences: agentData.model_preferences || {},
|
||||
personality_config: agentData.personality_config || {},
|
||||
memory_type: agentData.memory_type || 'conversation',
|
||||
available_tools: agentData.available_tools || [],
|
||||
resource_bindings: agentData.resource_bindings || []
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to create agent: ${response.status}`);
|
||||
}
|
||||
|
||||
const newAgent = await response.json();
|
||||
|
||||
// Convert to frontend format and add to state
|
||||
const convertedAgent: Agent = {
|
||||
id: newAgent.id,
|
||||
name: newAgent.name,
|
||||
description: newAgent.description,
|
||||
agent_type: newAgent.agent_type,
|
||||
capabilities: newAgent.capabilities || [],
|
||||
usage_count: newAgent.usage_count || 0,
|
||||
last_used: newAgent.last_used,
|
||||
is_active: newAgent.is_active,
|
||||
created_at: newAgent.created_at
|
||||
};
|
||||
|
||||
setAgents(prev => [convertedAgent, ...prev]);
|
||||
setShowCreateModal(false);
|
||||
} catch (error) {
|
||||
console.error('Failed to create agent:', error);
|
||||
alert('Failed to create agent. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleExecuteAgent = async (agentId: string, taskDescription: string, parameters: Record<string, any>) => {
|
||||
try {
|
||||
const authToken = localStorage.getItem('gt2_token');
|
||||
const response = await fetch(`http://localhost:8001/api/v1/agents/${agentId}/execute`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
task_description: taskDescription,
|
||||
task_parameters: parameters,
|
||||
execution_context: {}
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to execute agent: ${response.status}`);
|
||||
}
|
||||
|
||||
const execution = await response.json();
|
||||
|
||||
// Convert to frontend format
|
||||
const convertedExecution: AgentExecution = {
|
||||
id: execution.id,
|
||||
agent_id: execution.agent_id,
|
||||
task_description: execution.task_description,
|
||||
task_parameters: execution.task_parameters || {},
|
||||
status: execution.status,
|
||||
progress_percentage: execution.progress_percentage || 0,
|
||||
current_step: execution.current_step,
|
||||
result_data: execution.result_data || {},
|
||||
output_artifacts: execution.output_artifacts || [],
|
||||
tokens_used: execution.tokens_used || 0,
|
||||
cost_cents: execution.cost_cents || 0,
|
||||
tool_calls_count: execution.tool_calls_count || 0,
|
||||
started_at: execution.started_at,
|
||||
completed_at: execution.completed_at,
|
||||
created_at: execution.created_at
|
||||
};
|
||||
|
||||
setCurrentExecution(convertedExecution);
|
||||
setExecutions(prev => [convertedExecution, ...prev]);
|
||||
|
||||
// Poll for updates if execution is running
|
||||
if (convertedExecution.status === 'running' || convertedExecution.status === 'pending') {
|
||||
const pollExecution = async () => {
|
||||
try {
|
||||
const statusResponse = await fetch(`http://localhost:8001/api/v1/agents/executions/${execution.id}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`
|
||||
}
|
||||
});
|
||||
|
||||
if (statusResponse.ok) {
|
||||
const updatedExecution = await statusResponse.json();
|
||||
const convertedUpdate: AgentExecution = {
|
||||
id: updatedExecution.id,
|
||||
agent_id: updatedExecution.agent_id,
|
||||
task_description: updatedExecution.task_description,
|
||||
task_parameters: updatedExecution.task_parameters || {},
|
||||
status: updatedExecution.status,
|
||||
progress_percentage: updatedExecution.progress_percentage || 0,
|
||||
current_step: updatedExecution.current_step,
|
||||
result_data: updatedExecution.result_data || {},
|
||||
output_artifacts: updatedExecution.output_artifacts || [],
|
||||
tokens_used: updatedExecution.tokens_used || 0,
|
||||
cost_cents: updatedExecution.cost_cents || 0,
|
||||
tool_calls_count: updatedExecution.tool_calls_count || 0,
|
||||
started_at: updatedExecution.started_at,
|
||||
completed_at: updatedExecution.completed_at,
|
||||
created_at: updatedExecution.created_at
|
||||
};
|
||||
|
||||
setCurrentExecution(convertedUpdate);
|
||||
|
||||
// Continue polling if still running
|
||||
if (convertedUpdate.status === 'running' || convertedUpdate.status === 'pending') {
|
||||
setTimeout(pollExecution, 2000);
|
||||
}
|
||||
}
|
||||
} catch (pollError) {
|
||||
console.error('Error polling execution status:', pollError);
|
||||
}
|
||||
};
|
||||
|
||||
// Start polling after 2 seconds
|
||||
setTimeout(pollExecution, 2000);
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to execute agent:', error);
|
||||
alert('Failed to execute agent. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditAgent = (agent: Agent) => {
|
||||
// TODO: Implement edit functionality
|
||||
console.log('Edit agent:', agent);
|
||||
};
|
||||
|
||||
const handleDeleteAgent = async (agentId: string) => {
|
||||
try {
|
||||
const authToken = localStorage.getItem('gt2_token');
|
||||
const response = await fetch(`http://localhost:8001/api/v1/agents/${agentId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to delete agent: ${response.status}`);
|
||||
}
|
||||
|
||||
setAgents(prev => prev.filter(agent => agent.id !== agentId));
|
||||
} catch (error) {
|
||||
console.error('Failed to delete agent:', error);
|
||||
alert('Failed to delete agent. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCloneAgent = (agent: Agent) => {
|
||||
// TODO: Implement clone functionality
|
||||
console.log('Clone agent:', agent);
|
||||
};
|
||||
|
||||
const openExecuteModal = (agentId: string) => {
|
||||
const agent = agents.find(a => a.id === agentId);
|
||||
setSelectedAgent(agent || null);
|
||||
setCurrentExecution(null);
|
||||
setShowExecuteModal(true);
|
||||
};
|
||||
|
||||
// Calculate stats
|
||||
const totalAgents = agents.length;
|
||||
const activeAgents = agents.filter(a => a.is_active).length;
|
||||
const totalExecutions = executions.length;
|
||||
const totalTokensUsed = executions.reduce((sum, exec) => sum + exec.tokens_used, 0);
|
||||
const totalCost = executions.reduce((sum, exec) => sum + exec.cost_cents, 0);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-gt-green border-t-transparent"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">AI Agents</h1>
|
||||
<p className="text-gray-600">Create and manage AI agents for automated tasks</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateModal(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Agent
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Bot className="h-4 w-4 text-blue-600" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Total Agents</p>
|
||||
<p className="text-xl font-semibold">{totalAgents}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Zap className="h-4 w-4 text-green-600" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Active</p>
|
||||
<p className="text-xl font-semibold">{activeAgents}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Activity className="h-4 w-4 text-purple-600" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Executions</p>
|
||||
<p className="text-xl font-semibold">{totalExecutions}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Brain className="h-4 w-4 text-orange-600" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Tokens Used</p>
|
||||
<p className="text-xl font-semibold">{totalTokensUsed.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
<DollarSign className="h-4 w-4 text-green-600" />
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Total Cost</p>
|
||||
<p className="text-xl font-semibold">${(totalCost / 100).toFixed(2)}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="h-4 w-4 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="Search agents..."
|
||||
value={searchQuery}
|
||||
onChange={(e: any) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={selectedType}
|
||||
onChange={(e) => setSelectedType((e as React.ChangeEvent<HTMLSelectElement>).target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gt-green focus:border-transparent"
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
<option value="research">Research</option>
|
||||
<option value="coding">Coding</option>
|
||||
<option value="analysis">Analysis</option>
|
||||
<option value="custom">Custom</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Agents Grid */}
|
||||
{filteredAgents.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="text-center py-12">
|
||||
<Bot className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
{searchQuery || selectedType !== 'all' ? 'No agents found' : 'No agents yet'}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{searchQuery || selectedType !== 'all'
|
||||
? 'Try adjusting your search criteria'
|
||||
: 'Create your first AI agent to get started'
|
||||
}
|
||||
</p>
|
||||
{!searchQuery && selectedType === 'all' && (
|
||||
<Button onClick={() => setShowCreateModal(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Your First Agent
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredAgents.map((agent) => (
|
||||
<AgentCard
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
onExecute={openExecuteModal}
|
||||
onEdit={handleEditAgent}
|
||||
onDelete={handleDeleteAgent}
|
||||
onClone={handleCloneAgent}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Agent Modal */}
|
||||
<AgentCreateModal
|
||||
isOpen={showCreateModal}
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSubmit={handleCreateAgent}
|
||||
/>
|
||||
|
||||
{/* Execute Agent Modal */}
|
||||
<AgentExecutionModal
|
||||
isOpen={showExecuteModal}
|
||||
onClose={() => {
|
||||
setShowExecuteModal(false);
|
||||
setSelectedAgent(null);
|
||||
setCurrentExecution(null);
|
||||
}}
|
||||
agent={selectedAgent}
|
||||
onExecute={handleExecuteAgent}
|
||||
execution={currentExecution}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AgentsPage() {
|
||||
return (
|
||||
<AppLayout>
|
||||
<AgentsPageContent />
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
747
apps/tenant-app/src/app/agents/page.tsx
Normal file
747
apps/tenant-app/src/app/agents/page.tsx
Normal file
@@ -0,0 +1,747 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import dynamic from 'next/dynamic';
|
||||
import { AppLayout } from '@/components/layout/app-layout';
|
||||
import { AgentGallery } from '@/components/agents/agent-gallery';
|
||||
import { AgentQuickTile } from '@/components/agents/agent-quick-tile';
|
||||
import { AuthGuard } from '@/components/auth/auth-guard';
|
||||
import { GT2_CAPABILITIES } from '@/lib/capabilities';
|
||||
import { agentService, type EnhancedAgent } from '@/services';
|
||||
import { getFavoriteAgents, updateFavoriteAgents } from '@/services/user';
|
||||
import { Bot, Plus, LayoutGrid, List, Star, Upload } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Search } from 'lucide-react';
|
||||
import { usePageTitle } from '@/hooks/use-page-title';
|
||||
import { useDebouncedValue } from '@/lib/utils';
|
||||
|
||||
// Dynamically import heavy modal components for better performance
|
||||
const FavoriteAgentSelectorModal = dynamic(
|
||||
() => import('@/components/agents/favorite-agent-selector-modal').then(mod => ({ default: mod.FavoriteAgentSelectorModal })),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
const AgentBulkImportModal = dynamic(
|
||||
() => import('@/components/agents/agent-bulk-import-modal').then(mod => ({ default: mod.AgentBulkImportModal })),
|
||||
{ ssr: false }
|
||||
);
|
||||
|
||||
type ViewMode = 'quick' | 'detailed';
|
||||
type SortBy = 'name' | 'created_at' | 'usage_count' | 'recent_usage' | 'my_most_used';
|
||||
|
||||
function AgentsPage() {
|
||||
usePageTitle('Agents');
|
||||
|
||||
const [agents, setAgents] = useState<EnhancedAgent[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const router = useRouter();
|
||||
const [triggerCreate, setTriggerCreate] = useState(false);
|
||||
|
||||
// Quick View state - Default to 'quick' view (favorites) as landing page
|
||||
const [viewMode, setViewMode] = useState<ViewMode>('quick');
|
||||
const [favoriteAgentIds, setFavoriteAgentIds] = useState<string[]>([]);
|
||||
const [showFavoriteSelector, setShowFavoriteSelector] = useState(false);
|
||||
const [showBulkImportModal, setShowBulkImportModal] = useState(false);
|
||||
const [loadingFavorites, setLoadingFavorites] = useState(true);
|
||||
|
||||
// Quick View filters
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const debouncedSearchQuery = useDebouncedValue(searchQuery, 300); // Performance optimization: debounce search
|
||||
const [selectedCategory, setSelectedCategory] = useState<string>('all');
|
||||
const [selectedTag, setSelectedTag] = useState<string>('all');
|
||||
const [selectedCreator, setSelectedCreator] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<SortBy>('recent_usage'); // Default to recently used
|
||||
|
||||
const loadAgents = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Build query parameters for backend usage tracking
|
||||
const params: any = {};
|
||||
|
||||
// Add sort parameter if using user-relative sorting
|
||||
if (sortBy === 'recent_usage' || sortBy === 'my_most_used') {
|
||||
params.sort_by = sortBy;
|
||||
}
|
||||
|
||||
const res = await agentService.listAgents(params);
|
||||
console.log('📋 Agent service response:', res);
|
||||
// Backend returns wrapped in ApiResponse: {data: {data: [], total: 0, limit: 50, offset: 0}, status: 200}
|
||||
if (res && res.data && res.data.data && Array.isArray(res.data.data)) {
|
||||
console.log('📋 Found agents in res.data.data:', res.data.data);
|
||||
// Log first agent's permission flags for debugging
|
||||
if (res.data.data.length > 0) {
|
||||
const firstAgent = res.data.data[0];
|
||||
console.log('🔐 First agent permissions (RAW from backend):', {
|
||||
name: firstAgent.name,
|
||||
can_edit: firstAgent.can_edit,
|
||||
can_edit_type: typeof firstAgent.can_edit,
|
||||
can_delete: firstAgent.can_delete,
|
||||
can_delete_type: typeof firstAgent.can_delete,
|
||||
is_owner: firstAgent.is_owner,
|
||||
is_owner_type: typeof firstAgent.is_owner
|
||||
});
|
||||
}
|
||||
// Adapt backend AgentResponse to frontend EnhancedAgent interface
|
||||
const adaptedAgents = res.data.data.map((agent: any) => {
|
||||
const adapted = {
|
||||
...agent,
|
||||
// Provide defaults for missing EnhancedAgent fields
|
||||
team_id: agent.team_id || '',
|
||||
disclaimer: agent.disclaimer || '',
|
||||
easy_prompts: agent.easy_prompts || [],
|
||||
visibility: agent.visibility || 'individual',
|
||||
featured: agent.featured || false,
|
||||
personality_type: agent.personality_type || 'minimal',
|
||||
custom_avatar_url: agent.custom_avatar_url || '',
|
||||
model_id: agent.model_id || agent.model || '',
|
||||
system_prompt: agent.system_prompt || '',
|
||||
model_parameters: agent.model_parameters || {},
|
||||
dataset_connection: agent.dataset_connection || 'all',
|
||||
selected_dataset_ids: agent.selected_dataset_ids || [],
|
||||
require_moderation: agent.require_moderation || false,
|
||||
blocked_terms: agent.blocked_terms || [],
|
||||
enabled_capabilities: agent.enabled_capabilities || [],
|
||||
mcp_integration_ids: agent.mcp_integration_ids || [],
|
||||
tool_configurations: agent.tool_configurations || {},
|
||||
collaborator_ids: agent.collaborator_ids || [],
|
||||
can_fork: agent.can_fork || true,
|
||||
parent_agent_id: agent.parent_agent_id,
|
||||
version: agent.version || 1,
|
||||
usage_count: agent.usage_count || 0,
|
||||
average_rating: agent.average_rating,
|
||||
tags: agent.tags || [],
|
||||
example_prompts: agent.example_prompts || [],
|
||||
safety_flags: agent.safety_flags || [],
|
||||
created_at: agent.created_at,
|
||||
updated_at: agent.updated_at,
|
||||
// Permission flags from backend - default to false for security
|
||||
can_edit: Boolean(agent.can_edit),
|
||||
can_delete: Boolean(agent.can_delete),
|
||||
is_owner: Boolean(agent.is_owner),
|
||||
// Creator information
|
||||
owner_name: agent.created_by_name || agent.owner_name
|
||||
};
|
||||
console.log('🔐 Adapted agent:', adapted.name, 'can_edit:', adapted.can_edit, 'can_delete:', adapted.can_delete);
|
||||
return adapted;
|
||||
});
|
||||
setAgents(adaptedAgents);
|
||||
} else if (res && res.data && Array.isArray(res.data)) {
|
||||
console.log('📋 Found agents in res.data:', res.data);
|
||||
// Map permission flags even for this path - default to false for security
|
||||
const mappedAgents = res.data.map((agent: any) => ({
|
||||
...agent,
|
||||
can_edit: Boolean(agent.can_edit),
|
||||
can_delete: Boolean(agent.can_delete),
|
||||
is_owner: Boolean(agent.is_owner),
|
||||
owner_name: agent.created_by_name || agent.owner_name
|
||||
}));
|
||||
setAgents(mappedAgents);
|
||||
} else if (Array.isArray(res)) {
|
||||
console.log('📋 Response is array:', res);
|
||||
// Map permission flags even for this path - default to false for security
|
||||
const mappedAgents = res.map((agent: any) => ({
|
||||
...agent,
|
||||
can_edit: Boolean(agent.can_edit),
|
||||
can_delete: Boolean(agent.can_delete),
|
||||
is_owner: Boolean(agent.is_owner)
|
||||
}));
|
||||
setAgents(mappedAgents);
|
||||
} else {
|
||||
console.log('📋 No agents found or unexpected response format:', res);
|
||||
setAgents([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error loading agents:', error);
|
||||
setAgents([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const loadFavorites = async () => {
|
||||
setLoadingFavorites(true);
|
||||
try {
|
||||
const res = await getFavoriteAgents();
|
||||
if (res.data?.favorite_agent_ids && res.data.favorite_agent_ids.length > 0) {
|
||||
// User has favorites set
|
||||
setFavoriteAgentIds(res.data.favorite_agent_ids);
|
||||
} else {
|
||||
// No favorites set - mark all agents as favorites by default
|
||||
// Wait for agents to load first
|
||||
if (agents.length === 0) {
|
||||
// Agents not loaded yet, wait for them
|
||||
setLoadingFavorites(false);
|
||||
return;
|
||||
}
|
||||
const allAgentIds = agents.map(agent => agent.id);
|
||||
setFavoriteAgentIds(allAgentIds);
|
||||
// Save to backend
|
||||
if (allAgentIds.length > 0) {
|
||||
try {
|
||||
await updateFavoriteAgents(allAgentIds);
|
||||
console.log('✅ All agents marked as favorites by default');
|
||||
} catch (saveError) {
|
||||
console.error('❌ Error saving default favorites:', saveError);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error loading favorite agents:', error);
|
||||
} finally {
|
||||
setLoadingFavorites(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadAgents();
|
||||
}, []);
|
||||
|
||||
// Load favorites after agents are loaded, or allow empty state if no agents exist
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
if (agents.length > 0 && loadingFavorites) {
|
||||
loadFavorites();
|
||||
} else if (agents.length === 0 && loadingFavorites) {
|
||||
// No agents visible to this user - allow empty favorites state
|
||||
setLoadingFavorites(false);
|
||||
}
|
||||
}
|
||||
}, [agents, loading]);
|
||||
|
||||
// Reload agents when sort or filter changes
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
loadAgents();
|
||||
}
|
||||
}, [sortBy]);
|
||||
|
||||
const handleSaveFavorites = async (newFavoriteIds: string[]) => {
|
||||
try {
|
||||
const res = await updateFavoriteAgents(newFavoriteIds);
|
||||
if (res.status >= 200 && res.status < 300) {
|
||||
setFavoriteAgentIds(newFavoriteIds);
|
||||
console.log('✅ Favorite agents updated');
|
||||
} else {
|
||||
throw new Error(res.error || 'Failed to update favorites');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error saving favorite agents:', error);
|
||||
alert('Failed to save favorite agents. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
// Get favorite agents (filtered list)
|
||||
const favoriteAgents = React.useMemo(() => {
|
||||
return agents.filter(agent => favoriteAgentIds.includes(agent.id));
|
||||
}, [agents, favoriteAgentIds]);
|
||||
|
||||
// Extract unique categories, tags, and creators from agents
|
||||
const { categories, tags, creators } = React.useMemo(() => {
|
||||
const categorySet = new Set<string>();
|
||||
const tagSet = new Set<string>();
|
||||
const creatorSet = new Set<string>();
|
||||
|
||||
agents.forEach(agent => {
|
||||
if (agent.category) categorySet.add(agent.category);
|
||||
agent.tags?.forEach(tag => tagSet.add(tag));
|
||||
if (agent.owner_name) creatorSet.add(agent.owner_name);
|
||||
});
|
||||
|
||||
return {
|
||||
categories: Array.from(categorySet).sort(),
|
||||
tags: Array.from(tagSet).sort(),
|
||||
creators: Array.from(creatorSet).sort()
|
||||
};
|
||||
}, [agents]);
|
||||
|
||||
// Filter and sort favorite agents for Quick View
|
||||
const filteredFavoriteAgents = React.useMemo(() => {
|
||||
let filtered = favoriteAgents.filter(agent => {
|
||||
const matchesSearch = !debouncedSearchQuery ||
|
||||
agent.name.toLowerCase().includes(debouncedSearchQuery.toLowerCase()) ||
|
||||
agent.description?.toLowerCase().includes(debouncedSearchQuery.toLowerCase());
|
||||
|
||||
const matchesCategory = selectedCategory === 'all' || agent.category === selectedCategory;
|
||||
const matchesTag = selectedTag === 'all' || agent.tags?.includes(selectedTag);
|
||||
const matchesCreator = selectedCreator === 'all' || agent.owner_name === selectedCreator;
|
||||
|
||||
return matchesSearch && matchesCategory && matchesTag && matchesCreator;
|
||||
});
|
||||
|
||||
// Sort agents locally (only if not using backend sorting)
|
||||
if (sortBy === 'recent_usage' || sortBy === 'my_most_used') {
|
||||
// Backend already sorted, preserve order
|
||||
return filtered;
|
||||
}
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
return a.name.localeCompare(b.name);
|
||||
case 'usage_count':
|
||||
return (b.usage_count || 0) - (a.usage_count || 0);
|
||||
case 'created_at':
|
||||
default:
|
||||
return new Date(b.created_at || 0).getTime() - new Date(a.created_at || 0).getTime();
|
||||
}
|
||||
});
|
||||
|
||||
return filtered;
|
||||
}, [favoriteAgents, debouncedSearchQuery, selectedCategory, selectedTag, selectedCreator, sortBy]);
|
||||
|
||||
const handleSelectAgent = (agent: EnhancedAgent) => {
|
||||
router.push(`/chat?agent=${agent.id}`);
|
||||
};
|
||||
|
||||
const handleCreateAgent = async (agentData: any) => {
|
||||
try {
|
||||
console.log('🚀 Creating agent with data:', agentData);
|
||||
|
||||
// Essential fields that backend expects including model_id
|
||||
const createRequest = {
|
||||
name: agentData.name,
|
||||
description: agentData.description || "",
|
||||
category: agentData.category || agentData.agent_type || "general",
|
||||
model_id: agentData.model_id,
|
||||
temperature: agentData.temperature || agentData.model_parameters?.temperature,
|
||||
max_tokens: agentData.max_tokens || agentData.model_parameters?.max_tokens,
|
||||
prompt_template: agentData.system_prompt,
|
||||
tags: agentData.tags || [],
|
||||
selected_dataset_ids: agentData.selected_dataset_ids || [],
|
||||
visibility: agentData.visibility || "individual",
|
||||
disclaimer: agentData.disclaimer,
|
||||
easy_prompts: agentData.easy_prompts || []
|
||||
};
|
||||
|
||||
console.log('📦 Sending request:', createRequest);
|
||||
|
||||
const result = await agentService.createAgent(createRequest);
|
||||
console.log('📥 Backend result:', result);
|
||||
|
||||
if (result.data && result.status >= 200 && result.status < 300) {
|
||||
// Refresh the agents list
|
||||
const refreshResult = await agentService.listAgents();
|
||||
if (refreshResult && refreshResult.data && refreshResult.data.data && Array.isArray(refreshResult.data.data)) {
|
||||
const adaptedAgents = refreshResult.data.data.map((agent: any) => ({
|
||||
...agent,
|
||||
team_id: agent.team_id || '',
|
||||
disclaimer: agent.disclaimer || '',
|
||||
easy_prompts: agent.easy_prompts || [],
|
||||
visibility: agent.visibility || 'individual',
|
||||
featured: agent.featured || false,
|
||||
personality_type: agent.personality_type || 'minimal',
|
||||
custom_avatar_url: agent.custom_avatar_url || '',
|
||||
model_id: agent.model_id || agent.model || '',
|
||||
system_prompt: agent.system_prompt || '',
|
||||
model_parameters: agent.model_parameters || {},
|
||||
dataset_connection: agent.dataset_connection || 'all',
|
||||
selected_dataset_ids: agent.selected_dataset_ids || [],
|
||||
require_moderation: agent.require_moderation || false,
|
||||
blocked_terms: agent.blocked_terms || [],
|
||||
enabled_capabilities: agent.enabled_capabilities || [],
|
||||
mcp_integration_ids: agent.mcp_integration_ids || [],
|
||||
tool_configurations: agent.tool_configurations || {},
|
||||
collaborator_ids: agent.collaborator_ids || [],
|
||||
can_fork: agent.can_fork || true,
|
||||
parent_agent_id: agent.parent_agent_id,
|
||||
version: agent.version || 1,
|
||||
usage_count: agent.usage_count || 0,
|
||||
average_rating: agent.average_rating,
|
||||
tags: agent.tags || [],
|
||||
example_prompts: agent.example_prompts || [],
|
||||
safety_flags: agent.safety_flags || [],
|
||||
created_at: agent.created_at,
|
||||
updated_at: agent.updated_at,
|
||||
can_edit: agent.can_edit === true,
|
||||
can_delete: agent.can_delete === true,
|
||||
is_owner: agent.is_owner === true,
|
||||
owner_name: agent.created_by_name || agent.owner_name
|
||||
}));
|
||||
setAgents(adaptedAgents);
|
||||
} else if (refreshResult && refreshResult.data && Array.isArray(refreshResult.data)) {
|
||||
setAgents(refreshResult.data);
|
||||
} else if (Array.isArray(refreshResult)) {
|
||||
setAgents(refreshResult);
|
||||
}
|
||||
console.log('✅ Agent created successfully');
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to create agent');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error creating agent:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditAgent = async (agentData: any) => {
|
||||
try {
|
||||
console.log('📝 Updating agent with data:', agentData);
|
||||
|
||||
// The agentData should contain the agent ID and update fields
|
||||
const updateRequest = {
|
||||
name: agentData.name,
|
||||
description: agentData.description || "",
|
||||
category: agentData.category || agentData.agent_type || "general",
|
||||
prompt_template: agentData.system_prompt || agentData.prompt_template || "",
|
||||
model: agentData.model_id || agentData.model || "",
|
||||
temperature: agentData.model_parameters?.temperature || 0.7,
|
||||
max_tokens: agentData.model_parameters?.max_tokens || 4096,
|
||||
personality_config: agentData.personality_profile || agentData.personality_config || {},
|
||||
resource_preferences: agentData.resource_preferences || {},
|
||||
tags: agentData.tags || [],
|
||||
is_favorite: agentData.is_favorite || false,
|
||||
visibility: agentData.visibility || "individual",
|
||||
selected_dataset_ids: agentData.selected_dataset_ids || [],
|
||||
disclaimer: agentData.disclaimer,
|
||||
easy_prompts: agentData.easy_prompts || [],
|
||||
team_shares: agentData.team_shares
|
||||
};
|
||||
|
||||
console.log('📦 Sending update request:', updateRequest);
|
||||
|
||||
const result = await agentService.updateAgent(agentData.id, updateRequest);
|
||||
console.log('📥 Backend update result:', result);
|
||||
|
||||
if (result.data && result.status >= 200 && result.status < 300) {
|
||||
// Refresh the agents list
|
||||
const refreshResult = await agentService.listAgents();
|
||||
if (refreshResult && refreshResult.data && refreshResult.data.data && Array.isArray(refreshResult.data.data)) {
|
||||
const adaptedAgents = refreshResult.data.data.map((agent: any) => ({
|
||||
...agent,
|
||||
team_id: agent.team_id || '',
|
||||
disclaimer: agent.disclaimer || '',
|
||||
easy_prompts: agent.easy_prompts || [],
|
||||
visibility: agent.visibility || 'individual',
|
||||
featured: agent.featured || false,
|
||||
personality_type: agent.personality_type || 'minimal',
|
||||
custom_avatar_url: agent.custom_avatar_url || '',
|
||||
model_id: agent.model_id || agent.model || '',
|
||||
system_prompt: agent.system_prompt || '',
|
||||
model_parameters: agent.model_parameters || {},
|
||||
dataset_connection: agent.dataset_connection || 'all',
|
||||
selected_dataset_ids: agent.selected_dataset_ids || [],
|
||||
require_moderation: agent.require_moderation || false,
|
||||
blocked_terms: agent.blocked_terms || [],
|
||||
enabled_capabilities: agent.enabled_capabilities || [],
|
||||
mcp_integration_ids: agent.mcp_integration_ids || [],
|
||||
tool_configurations: agent.tool_configurations || {},
|
||||
collaborator_ids: agent.collaborator_ids || [],
|
||||
can_fork: agent.can_fork || true,
|
||||
parent_agent_id: agent.parent_agent_id,
|
||||
version: agent.version || 1,
|
||||
usage_count: agent.usage_count || 0,
|
||||
average_rating: agent.average_rating,
|
||||
tags: agent.tags || [],
|
||||
example_prompts: agent.example_prompts || [],
|
||||
safety_flags: agent.safety_flags || [],
|
||||
created_at: agent.created_at,
|
||||
updated_at: agent.updated_at,
|
||||
can_edit: agent.can_edit === true,
|
||||
can_delete: agent.can_delete === true,
|
||||
is_owner: agent.is_owner === true,
|
||||
owner_name: agent.created_by_name || agent.owner_name
|
||||
}));
|
||||
setAgents(adaptedAgents);
|
||||
} else if (refreshResult && refreshResult.data && Array.isArray(refreshResult.data)) {
|
||||
setAgents(refreshResult.data);
|
||||
} else if (Array.isArray(refreshResult)) {
|
||||
setAgents(refreshResult);
|
||||
}
|
||||
console.log('✅ Agent updated successfully');
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to update agent');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error updating agent:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteAgent = async (agentId: string) => {
|
||||
try {
|
||||
const result = await agentService.deleteAgent(agentId);
|
||||
if (result.status >= 200 && result.status < 300) {
|
||||
// Remove from local state (archived agents are filtered out)
|
||||
setAgents(prev => prev.filter(agent => agent.id !== agentId));
|
||||
console.log('✅ Agent archived successfully');
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to archive agent');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error archiving agent:', error);
|
||||
alert('Failed to archive agent. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDuplicateAgent = async (agent: EnhancedAgent) => {
|
||||
try {
|
||||
const newName = `${agent.name} (Copy)`;
|
||||
const result = await agentService.forkAgent(agent.id, newName);
|
||||
|
||||
if (result.data && result.status >= 200 && result.status < 300) {
|
||||
// Refresh the agents list
|
||||
await loadAgents();
|
||||
console.log('✅ Agent duplicated successfully');
|
||||
} else {
|
||||
throw new Error(result.error || 'Failed to duplicate agent');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error duplicating agent:', error);
|
||||
alert('Failed to duplicate agent. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleViewHistory = (agent: EnhancedAgent) => {
|
||||
// Navigate to chat page with agent filter
|
||||
router.push(`/chat?agent=${agent.id}`);
|
||||
};
|
||||
|
||||
const handleOpenCreateAgent = () => {
|
||||
setTriggerCreate(true);
|
||||
};
|
||||
|
||||
|
||||
if (loading || loadingFavorites) {
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="max-w-7xl mx-auto p-6">
|
||||
<div className="text-center py-16">
|
||||
<div className="text-lg text-gray-600">Loading agents...</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<AppLayout>
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-3">
|
||||
<Bot className="w-8 h-8 text-gt-green" />
|
||||
{viewMode === 'quick' ? 'Favorite Agents' : 'Agent Configuration'}
|
||||
</h1>
|
||||
{/* Removed subtitle text per issue #167 requirements */}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Action Button (changes based on view mode) - LEFT position */}
|
||||
{viewMode === 'quick' ? (
|
||||
<Button
|
||||
className="bg-green-500 hover:bg-green-600 text-white px-4 py-2 focus:ring-green-500"
|
||||
onClick={() => setShowFavoriteSelector(true)}
|
||||
>
|
||||
<Star className="w-4 h-4 mr-2" />
|
||||
Add Favorites
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setShowBulkImportModal(true)}
|
||||
className="px-4 py-2"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Import Agent
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleOpenCreateAgent}
|
||||
className="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 focus:ring-blue-500"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Agent
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* View Mode Toggle - Color-coded: Blue = Configuration, Green = Navigation - RIGHT position */}
|
||||
{viewMode === 'quick' ? (
|
||||
<Button
|
||||
onClick={() => setViewMode('detailed')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white focus:ring-blue-500"
|
||||
>
|
||||
<List className="w-4 h-4" />
|
||||
Agent Configuration
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
onClick={() => setViewMode('quick')}
|
||||
className="flex items-center gap-2 px-4 py-2 bg-green-500 hover:bg-green-600 text-white focus:ring-green-500"
|
||||
>
|
||||
<LayoutGrid className="w-4 h-4" />
|
||||
Back to Favorites
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content: Quick View or Detailed View */}
|
||||
{viewMode === 'quick' ? (
|
||||
<>
|
||||
{/* Quick View: Empty State or Agent Tiles */}
|
||||
{favoriteAgentIds.length === 0 ? (
|
||||
<div className="bg-white rounded-lg shadow-sm border p-12">
|
||||
<div className="text-center max-w-md mx-auto">
|
||||
<Star className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-xl font-semibold text-gray-900 mb-2">
|
||||
No favorites selected
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
Click on the button below to select your Favorite Agents from the list of agents available in the catalog.
|
||||
</p>
|
||||
<Button
|
||||
onClick={() => setShowFavoriteSelector(true)}
|
||||
className="bg-green-500 hover:bg-green-600 text-white focus:ring-green-500"
|
||||
>
|
||||
<Star className="w-4 h-4 mr-2" />
|
||||
Add Favorites
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Search and Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4 z-10" />
|
||||
<Input
|
||||
placeholder="Search favorite agents..."
|
||||
value={searchQuery}
|
||||
onChange={(value: string) => setSearchQuery(value)}
|
||||
className="pl-10"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex gap-2 items-center">
|
||||
<Select value={selectedCategory} onValueChange={setSelectedCategory}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Category" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[100] backdrop-blur-sm bg-white/95 border shadow-lg" position="popper" sideOffset={5}>
|
||||
<SelectItem value="all">All Categories</SelectItem>
|
||||
{categories.map(category => (
|
||||
<SelectItem key={category} value={category}>
|
||||
{category}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={selectedTag} onValueChange={setSelectedTag}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="Tag" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[100] backdrop-blur-sm bg-white/95 border shadow-lg" position="popper" sideOffset={5}>
|
||||
<SelectItem value="all">All Tags</SelectItem>
|
||||
{tags.map(tag => (
|
||||
<SelectItem key={tag} value={tag}>
|
||||
{tag}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={selectedCreator} onValueChange={setSelectedCreator}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Creator" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[100] backdrop-blur-sm bg-white/95 border shadow-lg" position="popper" sideOffset={5}>
|
||||
<SelectItem value="all">All Creators</SelectItem>
|
||||
{creators.map(creator => (
|
||||
<SelectItem key={creator} value={creator}>
|
||||
{creator}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select value={sortBy} onValueChange={(value) => setSortBy(value as SortBy)}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="z-[100] backdrop-blur-sm bg-white/95 border shadow-lg" position="popper" sideOffset={5}>
|
||||
<SelectItem value="created_at">Date Created</SelectItem>
|
||||
<SelectItem value="name">Name</SelectItem>
|
||||
<SelectItem value="usage_count">Usage (Global)</SelectItem>
|
||||
<SelectItem value="recent_usage">Recently Used</SelectItem>
|
||||
<SelectItem value="my_most_used">Most Used</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent Tiles Grid (4 columns) */}
|
||||
{filteredFavoriteAgents.length === 0 ? (
|
||||
<div className="text-center py-12 bg-white rounded-lg border">
|
||||
<Search className="w-12 h-12 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No agents found</h3>
|
||||
<p className="text-gray-600">Try adjusting your search or filter criteria.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-[repeat(auto-fit,minmax(280px,1fr))] gap-4">
|
||||
{filteredFavoriteAgents.map((agent) => (
|
||||
<AgentQuickTile
|
||||
key={agent.id}
|
||||
agent={agent}
|
||||
onSelect={handleSelectAgent}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* Detailed View: Existing AgentGallery */
|
||||
<AgentGallery
|
||||
agents={agents}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
onCreateAgent={handleCreateAgent}
|
||||
onEditAgent={handleEditAgent}
|
||||
onDeleteAgent={handleDeleteAgent}
|
||||
onDuplicateAgent={handleDuplicateAgent}
|
||||
onViewHistory={handleViewHistory}
|
||||
hideHeader={true}
|
||||
className="mt-0"
|
||||
triggerCreate={triggerCreate}
|
||||
onTriggerComplete={() => setTriggerCreate(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Favorite Agent Selector Modal */}
|
||||
<FavoriteAgentSelectorModal
|
||||
isOpen={showFavoriteSelector}
|
||||
onClose={() => setShowFavoriteSelector(false)}
|
||||
agents={agents}
|
||||
currentFavorites={favoriteAgentIds}
|
||||
onSave={handleSaveFavorites}
|
||||
/>
|
||||
|
||||
{/* Bulk Import Modal */}
|
||||
<AgentBulkImportModal
|
||||
isOpen={showBulkImportModal}
|
||||
onClose={() => setShowBulkImportModal(false)}
|
||||
onImportComplete={loadAgents}
|
||||
/>
|
||||
</div>
|
||||
</AppLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<AuthGuard requiredCapabilities={[GT2_CAPABILITIES.AGENTS_READ]}>
|
||||
<AgentsPage />
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
85
apps/tenant-app/src/app/api/tenant-info/route.ts
Normal file
85
apps/tenant-app/src/app/api/tenant-info/route.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
/**
|
||||
* Server-side API route to fetch tenant information from Control Panel backend
|
||||
*
|
||||
* This route runs on the Next.js server, so it can communicate with the Control Panel
|
||||
* backend without CORS issues. The client fetches from this same-origin endpoint.
|
||||
*
|
||||
* Caching disabled to ensure immediate updates when tenant name changes in Control Panel.
|
||||
*/
|
||||
|
||||
// Disable Next.js route caching (force fresh data on every request)
|
||||
export const dynamic = 'force-dynamic';
|
||||
export const revalidate = 0;
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Get configuration from server environment variables
|
||||
const tenantDomain = process.env.TENANT_DOMAIN || 'test-company';
|
||||
const controlPanelUrl = process.env.CONTROL_PANEL_URL || 'http://localhost:8001';
|
||||
|
||||
// Server-to-server request (no CORS)
|
||||
// Disable caching to ensure fresh tenant data
|
||||
const response = await fetch(
|
||||
`${controlPanelUrl}/api/v1/tenant-info?tenant_domain=${tenantDomain}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-cache',
|
||||
},
|
||||
cache: 'no-store',
|
||||
// Server-side fetch with reasonable timeout
|
||||
signal: AbortSignal.timeout(5000),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: errorData.detail || 'Failed to fetch tenant info',
|
||||
status: response.status
|
||||
},
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
// Validate response has required fields
|
||||
if (!data.name || !data.domain) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Invalid tenant info response from Control Panel' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
|
||||
// Return with no-cache headers to prevent browser caching
|
||||
return NextResponse.json(data, {
|
||||
headers: {
|
||||
'Cache-Control': 'no-store, no-cache, must-revalidate, proxy-revalidate',
|
||||
'Pragma': 'no-cache',
|
||||
'Expires': '0',
|
||||
}
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('Server-side tenant info fetch error:', error);
|
||||
|
||||
// Check if it's a timeout error
|
||||
if (error instanceof Error && error.name === 'TimeoutError') {
|
||||
return NextResponse.json(
|
||||
{ error: 'Control Panel backend timeout' },
|
||||
{ status: 504 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Failed to fetch tenant information' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
147
apps/tenant-app/src/app/api/v1/[...path]/route.ts
Normal file
147
apps/tenant-app/src/app/api/v1/[...path]/route.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* Next.js API Route Proxy
|
||||
*
|
||||
* Proxies all /api/v1/* requests from browser to tenant-backend via Docker network.
|
||||
* This is required because Next.js rewrites don't work for client-side fetch() calls.
|
||||
*
|
||||
* Flow:
|
||||
* 1. Browser → fetch('/api/v1/models')
|
||||
* 2. Next.js catches via this route (server-side)
|
||||
* 3. Proxy → http://tenant-backend:8000/api/v1/models (Docker network)
|
||||
* 4. Response → Return to browser
|
||||
*/
|
||||
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
|
||||
const BACKEND_URL = process.env.INTERNAL_BACKEND_URL || 'http://tenant-backend:8000';
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ path: string[] }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy request to tenant-backend via Docker network
|
||||
*/
|
||||
async function proxyRequest(
|
||||
request: NextRequest,
|
||||
method: string,
|
||||
path: string
|
||||
): Promise<NextResponse> {
|
||||
try {
|
||||
const url = `${BACKEND_URL}/api/v1/${path}`;
|
||||
|
||||
console.log(`[API Proxy] ${method} /api/v1/${path} → ${url}`);
|
||||
|
||||
// Forward body for POST/PUT/PATCH
|
||||
let body: string | FormData | undefined;
|
||||
const contentType = request.headers.get('content-type');
|
||||
const isMultipart = contentType?.includes('multipart/form-data');
|
||||
|
||||
if (['POST', 'PUT', 'PATCH'].includes(method)) {
|
||||
if (isMultipart) {
|
||||
body = await request.formData();
|
||||
} else {
|
||||
body = await request.text();
|
||||
}
|
||||
}
|
||||
|
||||
// Forward headers (auth, tenant domain, content-type)
|
||||
const headers = new Headers();
|
||||
request.headers.forEach((value, key) => {
|
||||
const lowerKey = key.toLowerCase();
|
||||
// Don't forward host-related headers
|
||||
// Don't forward content-length or content-type for multipart/form-data
|
||||
// (fetch will generate new headers with correct boundary)
|
||||
if (!lowerKey.startsWith('host') &&
|
||||
!lowerKey.startsWith('connection') &&
|
||||
!(isMultipart && lowerKey === 'content-length') &&
|
||||
!(isMultipart && lowerKey === 'content-type')) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
// Forward query parameters
|
||||
const searchParams = request.nextUrl.searchParams.toString();
|
||||
const finalUrl = searchParams ? `${url}?${searchParams}` : url;
|
||||
|
||||
// Make server-side request to backend via Docker network
|
||||
// Follow redirects automatically (FastAPI trailing slash redirects)
|
||||
const response = await fetch(finalUrl, {
|
||||
method,
|
||||
headers,
|
||||
body,
|
||||
redirect: 'follow',
|
||||
});
|
||||
|
||||
console.log(`[API Proxy] Response: ${response.status} ${response.statusText}`);
|
||||
|
||||
// Forward response headers
|
||||
const responseHeaders = new Headers();
|
||||
response.headers.forEach((value, key) => {
|
||||
responseHeaders.set(key, value);
|
||||
});
|
||||
|
||||
// Return response to browser
|
||||
return new NextResponse(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: responseHeaders,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(`[API Proxy] Error:`, error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Proxy error',
|
||||
message: error instanceof Error ? error.message : 'Unknown error'
|
||||
},
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// HTTP Method Handlers
|
||||
export async function GET(request: NextRequest, context: RouteContext) {
|
||||
const params = await context.params;
|
||||
let path = params.path.join('/');
|
||||
// Preserve trailing slash from original URL
|
||||
if (request.nextUrl.pathname.endsWith('/')) {
|
||||
path = path + '/';
|
||||
}
|
||||
return proxyRequest(request, 'GET', path);
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest, context: RouteContext) {
|
||||
const params = await context.params;
|
||||
let path = params.path.join('/');
|
||||
if (request.nextUrl.pathname.endsWith('/')) {
|
||||
path = path + '/';
|
||||
}
|
||||
return proxyRequest(request, 'POST', path);
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, context: RouteContext) {
|
||||
const params = await context.params;
|
||||
let path = params.path.join('/');
|
||||
if (request.nextUrl.pathname.endsWith('/')) {
|
||||
path = path + '/';
|
||||
}
|
||||
return proxyRequest(request, 'PUT', path);
|
||||
}
|
||||
|
||||
export async function DELETE(request: NextRequest, context: RouteContext) {
|
||||
const params = await context.params;
|
||||
let path = params.path.join('/');
|
||||
if (request.nextUrl.pathname.endsWith('/')) {
|
||||
path = path + '/';
|
||||
}
|
||||
return proxyRequest(request, 'DELETE', path);
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, context: RouteContext) {
|
||||
const params = await context.params;
|
||||
let path = params.path.join('/');
|
||||
if (request.nextUrl.pathname.endsWith('/')) {
|
||||
path = path + '/';
|
||||
}
|
||||
return proxyRequest(request, 'PATCH', path);
|
||||
}
|
||||
2170
apps/tenant-app/src/app/chat/page.tsx
Normal file
2170
apps/tenant-app/src/app/chat/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
19
apps/tenant-app/src/app/conversations/page.tsx
Normal file
19
apps/tenant-app/src/app/conversations/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* Conversations page redirect
|
||||
*
|
||||
* This page has been removed. Users are redirected to the chat page.
|
||||
*/
|
||||
export default function ConversationsPage() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
router.replace('/chat');
|
||||
}, [router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
474
apps/tenant-app/src/app/datasets/page.tsx
Normal file
474
apps/tenant-app/src/app/datasets/page.tsx
Normal file
@@ -0,0 +1,474 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import { Plus, Search, Filter, Database, FileText, BarChart3,
|
||||
Trash2, Edit3, Eye, Lock, Users, Globe, Upload } from 'lucide-react';
|
||||
import {
|
||||
Dataset,
|
||||
Document,
|
||||
AccessGroup,
|
||||
AccessFilter,
|
||||
} from '@/services';
|
||||
import { AppLayout } from '@/components/layout/app-layout';
|
||||
import { AuthGuard } from '@/components/auth/auth-guard';
|
||||
import { GT2_CAPABILITIES } from '@/lib/capabilities';
|
||||
import {
|
||||
DatasetCard,
|
||||
DatasetCreateModal,
|
||||
DatasetEditModal,
|
||||
BulkUpload,
|
||||
DocumentSummaryModal,
|
||||
CreateDatasetData,
|
||||
UpdateDatasetData,
|
||||
DatasetDetailsDrawer
|
||||
} from '@/components/datasets';
|
||||
import { DatasetDocumentsModal } from '@/components/datasets/dataset-documents-modal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { usePageTitle } from '@/hooks/use-page-title';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import { useDatasets, useDatasetSummary, useCreateDataset, useUpdateDataset, useDeleteDataset, datasetKeys } from '@/hooks/use-datasets';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { formatStorageSize } from '@/lib/utils';
|
||||
|
||||
// Statistics interface
|
||||
interface DatasetSummary {
|
||||
total_datasets: number;
|
||||
owned_datasets: number;
|
||||
team_datasets: number;
|
||||
org_datasets: number;
|
||||
total_documents: number;
|
||||
assigned_documents: number;
|
||||
unassigned_documents: number;
|
||||
total_storage_mb: number;
|
||||
assigned_storage_mb: number;
|
||||
unassigned_storage_mb: number;
|
||||
is_admin?: boolean;
|
||||
total_tenant_storage_mb?: number;
|
||||
}
|
||||
|
||||
|
||||
|
||||
function DatasetsPageContent() {
|
||||
usePageTitle('Datasets');
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Filter and search state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [accessFilter, setAccessFilter] = useState<AccessFilter>('all');
|
||||
|
||||
// React Query hooks
|
||||
const { data: datasets = [], isLoading: loading } = useDatasets(accessFilter);
|
||||
const { data: summary = null } = useDatasetSummary();
|
||||
const createDataset = useCreateDataset();
|
||||
const updateDataset = useUpdateDataset();
|
||||
const deleteDataset = useDeleteDataset();
|
||||
|
||||
// Helper to refresh dataset data
|
||||
const refreshDatasets = () => {
|
||||
queryClient.invalidateQueries({ queryKey: datasetKeys.all });
|
||||
};
|
||||
|
||||
// UI state
|
||||
const [selectedDatasetId, setSelectedDatasetId] = useState<string | null>(null);
|
||||
const [showDetailsDrawer, setShowDetailsDrawer] = useState(false);
|
||||
const [selectedDatasets, setSelectedDatasets] = useState<string[]>([]);
|
||||
|
||||
// Modal states
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showBulkUpload, setShowBulkUpload] = useState(false);
|
||||
const [selectedDatasetForUpload, setSelectedDatasetForUpload] = useState<string>('');
|
||||
const [editingDataset, setEditingDataset] = useState<Dataset | null>(null);
|
||||
const [showDocumentSummary, setShowDocumentSummary] = useState(false);
|
||||
const [summaryDocumentId, setSummaryDocumentId] = useState<string>('');
|
||||
const [showDocumentsModal, setShowDocumentsModal] = useState(false);
|
||||
const [documentsDatasetId, setDocumentsDatasetId] = useState<string | null>(null);
|
||||
const [documentsDatasetName, setDocumentsDatasetName] = useState<string>('');
|
||||
const [lastUploadedDatasetId, setLastUploadedDatasetId] = useState<string | null>(null);
|
||||
const [initialDocuments, setInitialDocuments] = useState<any[]>([]); // Documents from recent upload
|
||||
|
||||
// Clear any stale dataset selections on mount to prevent foreign key errors
|
||||
useEffect(() => {
|
||||
setSelectedDatasetForUpload('');
|
||||
setSelectedDatasets([]);
|
||||
}, []);
|
||||
|
||||
// Dataset action handlers
|
||||
const handleCreateDataset = async (datasetData: CreateDatasetData) => {
|
||||
try {
|
||||
const result = await createDataset.mutateAsync({
|
||||
name: datasetData.name,
|
||||
description: datasetData.description,
|
||||
access_group: datasetData.access_group,
|
||||
team_members: datasetData.team_members,
|
||||
tags: datasetData.tags
|
||||
});
|
||||
console.log('Dataset created successfully:', result?.name);
|
||||
} catch (error) {
|
||||
console.error('Failed to create dataset:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDatasetView = (datasetId: string) => {
|
||||
const dataset = datasets.find(d => d.id === datasetId);
|
||||
setDocumentsDatasetId(datasetId);
|
||||
setDocumentsDatasetName(dataset?.name || '');
|
||||
setShowDocumentsModal(true);
|
||||
};
|
||||
|
||||
const handleDatasetEdit = (datasetId: string) => {
|
||||
const dataset = datasets.find(d => d.id === datasetId);
|
||||
if (dataset) {
|
||||
setEditingDataset(dataset);
|
||||
setShowEditModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateDataset = async (datasetId: string, updateData: UpdateDatasetData) => {
|
||||
try {
|
||||
const result = await updateDataset.mutateAsync({ datasetId, updateData });
|
||||
console.log('Dataset updated successfully:', result?.name);
|
||||
setShowEditModal(false);
|
||||
setEditingDataset(null);
|
||||
} catch (error) {
|
||||
console.error('Failed to update dataset:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDatasetDelete = async (datasetId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this dataset? This action cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await deleteDataset.mutateAsync(datasetId);
|
||||
console.log('Dataset deleted successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to delete dataset:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDatasetUpload = (datasetId: string) => {
|
||||
console.log('Uploading to dataset:', datasetId);
|
||||
|
||||
// Verify the dataset still exists in our current list
|
||||
const dataset = datasets.find(d => d.id === datasetId);
|
||||
if (!dataset) {
|
||||
console.error('Dataset not found:', datasetId);
|
||||
alert('Dataset not found. Please refresh the page and try again.');
|
||||
refreshDatasets(); // Refresh datasets
|
||||
return;
|
||||
}
|
||||
|
||||
// Store the dataset ID for routing after upload completes
|
||||
setLastUploadedDatasetId(datasetId);
|
||||
setSelectedDatasetForUpload(datasetId);
|
||||
setShowBulkUpload(true);
|
||||
};
|
||||
|
||||
const handleDatasetProcess = (datasetId: string) => {
|
||||
console.log('Processing dataset:', datasetId);
|
||||
// TODO: Trigger processing for all documents in dataset
|
||||
};
|
||||
|
||||
const handleDatasetReindex = (datasetId: string) => {
|
||||
console.log('Reindexing dataset:', datasetId);
|
||||
// TODO: Trigger reindexing
|
||||
};
|
||||
|
||||
|
||||
// Filter datasets based on search query
|
||||
const filteredDatasets = datasets.filter(dataset =>
|
||||
dataset.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
dataset.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
dataset.tags.some(tag => tag.toLowerCase().includes(searchQuery.toLowerCase()))
|
||||
);
|
||||
|
||||
// Get icon for access group
|
||||
const getAccessIcon = (accessGroup: AccessGroup) => {
|
||||
switch (accessGroup) {
|
||||
case 'individual': return <Lock className="w-4 h-4" />;
|
||||
case 'team': return <Users className="w-4 h-4" />;
|
||||
case 'organization': return <Globe className="w-4 h-4" />;
|
||||
default: return <Lock className="w-4 h-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
// Get access group color
|
||||
const getAccessColor = (accessGroup: AccessGroup) => {
|
||||
switch (accessGroup) {
|
||||
case 'individual': return 'text-gray-600';
|
||||
case 'team': return 'text-blue-600';
|
||||
case 'organization': return 'text-green-600';
|
||||
default: return 'text-gray-600';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-3">
|
||||
<Database className="w-8 h-8 text-gt-green" />
|
||||
Dataset Management Hub
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Manage your datasets and documents for RAG in one unified interface
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Statistics Cards */}
|
||||
{summary && (
|
||||
<div className={`grid grid-cols-1 gap-4 ${summary.is_admin ? 'md:grid-cols-3' : 'md:grid-cols-2'}`}>
|
||||
<button
|
||||
onClick={() => setAccessFilter('mine')}
|
||||
className="w-full bg-gradient-to-br from-green-50 to-green-100 p-4 rounded-lg hover:shadow-md transition-all cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-green-600 text-sm font-medium">My Datasets</p>
|
||||
<p className="text-2xl font-bold text-green-900">{summary.owned_datasets}</p>
|
||||
</div>
|
||||
<Lock className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<div className="bg-gradient-to-br from-orange-50 to-orange-100 p-4 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-orange-600 text-sm font-medium">My Storage</p>
|
||||
<p className="text-2xl font-bold text-orange-900">
|
||||
{formatStorageSize(summary.total_storage_mb)}
|
||||
</p>
|
||||
{/* TODO: Show % of allocation when storage_allocation_mb added to tenant schema */}
|
||||
</div>
|
||||
<BarChart3 className="w-8 h-8 text-orange-600" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{summary.is_admin && summary.total_tenant_storage_mb !== undefined && (
|
||||
<div className="bg-gradient-to-br from-blue-50 to-blue-100 p-4 rounded-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-blue-600 text-sm font-medium">Total Tenant Storage</p>
|
||||
<p className="text-2xl font-bold text-blue-900">
|
||||
{formatStorageSize(summary.total_tenant_storage_mb)}
|
||||
</p>
|
||||
</div>
|
||||
<BarChart3 className="w-8 h-8 text-blue-600" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main Content Area */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div className="space-y-6">
|
||||
{/* Controls */}
|
||||
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row gap-4 flex-1">
|
||||
{/* Search */}
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-4 h-4 z-10" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search datasets..."
|
||||
value={searchQuery}
|
||||
onChange={(value) => setSearchQuery(value)}
|
||||
className="pl-10"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Access Filter */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-4 h-4 text-gray-400" />
|
||||
<Select value={accessFilter} onValueChange={(value: AccessFilter) => setAccessFilter(value)}>
|
||||
<SelectTrigger className="w-40">
|
||||
<SelectValue placeholder="Filter by access" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Access</SelectItem>
|
||||
<SelectItem value="mine">My Datasets</SelectItem>
|
||||
<SelectItem value="org">Organization</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setShowBulkUpload(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Upload className="w-4 h-4" />
|
||||
Upload Documents
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
New Dataset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Dataset List */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gt-green"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredDatasets.map((dataset) => (
|
||||
<DatasetCard
|
||||
key={dataset.id}
|
||||
dataset={{
|
||||
...dataset,
|
||||
embedding_model: 'BAAI/bge-m3', // TODO: Get from dataset
|
||||
search_method: 'hybrid', // TODO: Get from dataset
|
||||
processing_status: 'idle' // TODO: Get actual status
|
||||
}}
|
||||
onView={handleDatasetView}
|
||||
onEdit={handleDatasetEdit}
|
||||
onDelete={handleDatasetDelete}
|
||||
onUpload={handleDatasetUpload}
|
||||
onProcess={handleDatasetProcess}
|
||||
onReindex={handleDatasetReindex}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && filteredDatasets.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<Database className="w-12 h-12 text-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">No datasets found</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{searchQuery
|
||||
? `No datasets match "${searchQuery}"`
|
||||
: "Create your first dataset to get started"
|
||||
}
|
||||
</p>
|
||||
<Button onClick={() => setShowCreateModal(true)}>
|
||||
Create Dataset
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Modals */}
|
||||
<DatasetCreateModal
|
||||
open={showCreateModal}
|
||||
onOpenChange={setShowCreateModal}
|
||||
onCreateDataset={handleCreateDataset}
|
||||
loading={createDataset.isPending}
|
||||
/>
|
||||
|
||||
<BulkUpload
|
||||
open={showBulkUpload}
|
||||
onOpenChange={(open) => {
|
||||
setShowBulkUpload(open);
|
||||
if (!open) {
|
||||
setSelectedDatasetForUpload(''); // Clear selection when modal closes
|
||||
}
|
||||
}}
|
||||
datasets={datasets.map(d => ({
|
||||
id: d.id,
|
||||
name: d.name,
|
||||
document_count: d.document_count
|
||||
}))}
|
||||
preselectedDatasetId={selectedDatasetForUpload}
|
||||
onCreateDataset={() => {
|
||||
setShowBulkUpload(false);
|
||||
setShowCreateModal(true);
|
||||
}}
|
||||
onUploadStart={(datasetId, documents) => {
|
||||
// Route to documents page immediately when upload starts
|
||||
// Store any initial documents to display immediately
|
||||
if (documents && documents.length > 0) {
|
||||
setInitialDocuments(documents);
|
||||
}
|
||||
handleDatasetView(datasetId);
|
||||
}}
|
||||
onUploadComplete={async (results) => {
|
||||
console.log('Upload documents completed:', results);
|
||||
// React Query will auto-refresh via cache invalidation
|
||||
}}
|
||||
/>
|
||||
|
||||
<DatasetEditModal
|
||||
open={showEditModal}
|
||||
onOpenChange={(open) => {
|
||||
setShowEditModal(open);
|
||||
if (!open) {
|
||||
setEditingDataset(null);
|
||||
}
|
||||
}}
|
||||
onUpdateDataset={handleUpdateDataset}
|
||||
dataset={editingDataset}
|
||||
loading={updateDataset.isPending}
|
||||
/>
|
||||
|
||||
<DatasetDetailsDrawer
|
||||
datasetId={selectedDatasetId}
|
||||
isOpen={showDetailsDrawer}
|
||||
onClose={() => {
|
||||
setShowDetailsDrawer(false);
|
||||
setSelectedDatasetId(null);
|
||||
}}
|
||||
onDatasetDeleted={refreshDatasets}
|
||||
onDatasetUpdated={refreshDatasets}
|
||||
/>
|
||||
|
||||
<DocumentSummaryModal
|
||||
open={showDocumentSummary}
|
||||
onOpenChange={(open) => {
|
||||
setShowDocumentSummary(open);
|
||||
if (!open) {
|
||||
setSummaryDocumentId('');
|
||||
}
|
||||
}}
|
||||
documentId={summaryDocumentId}
|
||||
/>
|
||||
|
||||
<DatasetDocumentsModal
|
||||
open={showDocumentsModal}
|
||||
onOpenChange={(open) => {
|
||||
setShowDocumentsModal(open);
|
||||
if (!open) {
|
||||
setDocumentsDatasetId(null);
|
||||
setDocumentsDatasetName('');
|
||||
setInitialDocuments([]); // Clear initial documents when modal closes
|
||||
}
|
||||
}}
|
||||
datasetId={documentsDatasetId}
|
||||
datasetName={documentsDatasetName}
|
||||
initialDocuments={initialDocuments}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DatasetsPage() {
|
||||
return (
|
||||
<AuthGuard requiredCapabilities={[GT2_CAPABILITIES.DATASETS_READ]}>
|
||||
<AppLayout>
|
||||
<DatasetsPageContent />
|
||||
</AppLayout>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
877
apps/tenant-app/src/app/documents/page.tsx
Normal file
877
apps/tenant-app/src/app/documents/page.tsx
Normal file
@@ -0,0 +1,877 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import { Header } from '@/components/layout/header';
|
||||
import { Sidebar } from '@/components/layout/sidebar';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LoadingScreen } from '@/components/ui/loading-screen';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { AuthGuard } from '@/components/auth/auth-guard';
|
||||
import { GT2_CAPABILITIES } from '@/lib/capabilities';
|
||||
import { listDocuments, listDatasets, uploadDocument, processDocument } from '@/services/documents';
|
||||
import {
|
||||
Upload,
|
||||
File,
|
||||
FileText,
|
||||
FileImage,
|
||||
FileCode,
|
||||
FileArchive,
|
||||
Search,
|
||||
Filter,
|
||||
Download,
|
||||
Trash2,
|
||||
Eye,
|
||||
MoreVertical,
|
||||
AlertCircle,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
Brain,
|
||||
Database,
|
||||
Layers,
|
||||
RefreshCw,
|
||||
Plus,
|
||||
FolderOpen,
|
||||
Tags,
|
||||
Calendar,
|
||||
User,
|
||||
FileCheck,
|
||||
Activity,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
interface Document {
|
||||
id: string;
|
||||
filename: string;
|
||||
original_name: string;
|
||||
file_type: string;
|
||||
file_size: number;
|
||||
processing_status: 'pending' | 'processing' | 'completed' | 'failed';
|
||||
chunk_count?: number;
|
||||
vector_count?: number;
|
||||
dataset_id?: string;
|
||||
dataset_name?: string;
|
||||
uploaded_at: string;
|
||||
processed_at?: string;
|
||||
error_message?: string;
|
||||
metadata: {
|
||||
pages?: number;
|
||||
language?: string;
|
||||
author?: string;
|
||||
created_date?: string;
|
||||
};
|
||||
processing_progress?: number;
|
||||
}
|
||||
|
||||
interface RAGDataset {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
document_count: number;
|
||||
chunk_count: number;
|
||||
vector_count: number;
|
||||
embedding_model: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
status: 'active' | 'processing' | 'inactive';
|
||||
storage_size_mb: number;
|
||||
}
|
||||
|
||||
function DocumentsPageContent() {
|
||||
const { user, isAuthenticated, isLoading } = useAuthStore();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [documents, setDocuments] = useState<Document[]>([]);
|
||||
const [datasets, setDatasets] = useState<RAGDataset[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [selectedDataset, setSelectedDataset] = useState<string>('');
|
||||
const [showCreateDataset, setShowCreateDataset] = useState(false);
|
||||
const [selectedDocuments, setSelectedDocuments] = useState<Set<string>>(new Set());
|
||||
const [dragActive, setDragActive] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Load real data from API
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
loadDocumentsAndDatasets();
|
||||
}
|
||||
}, [isAuthenticated]);
|
||||
|
||||
const loadDocumentsAndDatasets = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Load documents and datasets in parallel
|
||||
const [documentsResponse, datasetsResponse] = await Promise.all([
|
||||
listDocuments(),
|
||||
listDatasets()
|
||||
]);
|
||||
|
||||
if (documentsResponse.data) {
|
||||
const docsWithMetadata = documentsResponse.data.map(doc => ({
|
||||
id: doc.id,
|
||||
filename: doc.filename,
|
||||
original_name: doc.original_filename,
|
||||
file_type: doc.file_type,
|
||||
file_size: doc.file_size_bytes,
|
||||
processing_status: doc.processing_status,
|
||||
chunk_count: doc.chunk_count,
|
||||
vector_count: doc.vector_count,
|
||||
dataset_id: undefined, // Documents aren't necessarily in datasets
|
||||
dataset_name: 'Individual Document',
|
||||
uploaded_at: doc.created_at,
|
||||
processed_at: doc.processed_at,
|
||||
error_message: doc.error_details?.message,
|
||||
metadata: {
|
||||
// Add metadata extraction if available
|
||||
},
|
||||
processing_progress: doc.processing_status === 'processing' ? 50 : undefined
|
||||
}));
|
||||
setDocuments(docsWithMetadata);
|
||||
}
|
||||
|
||||
if (datasetsResponse.data) {
|
||||
const datasetsWithMetadata = datasetsResponse.data.map(ds => ({
|
||||
id: ds.id,
|
||||
name: ds.dataset_name,
|
||||
description: ds.description || '',
|
||||
document_count: ds.document_count,
|
||||
chunk_count: ds.chunk_count,
|
||||
vector_count: ds.vector_count,
|
||||
embedding_model: ds.embedding_model,
|
||||
created_at: ds.created_at,
|
||||
updated_at: ds.updated_at,
|
||||
status: 'active' as const,
|
||||
storage_size_mb: Math.round(ds.total_size_bytes / (1024 * 1024) * 100) / 100
|
||||
}));
|
||||
setDatasets(datasetsWithMetadata);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load documents and datasets:', error);
|
||||
// Fallback to empty arrays - user can still upload
|
||||
setDocuments([]);
|
||||
setDatasets([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Keep mock data as fallback for development if API fails
|
||||
const loadMockData = () => {
|
||||
if (isAuthenticated) {
|
||||
const mockDocuments: Document[] = [
|
||||
{
|
||||
id: '1',
|
||||
filename: 'company_handbook_2024.pdf',
|
||||
original_name: 'Company Handbook 2024.pdf',
|
||||
file_type: 'application/pdf',
|
||||
file_size: 2048576, // 2MB
|
||||
processing_status: 'completed',
|
||||
chunk_count: 45,
|
||||
vector_count: 45,
|
||||
dataset_id: 'ds_1',
|
||||
dataset_name: 'Company Policies',
|
||||
uploaded_at: '2024-01-15T10:30:00Z',
|
||||
processed_at: '2024-01-15T10:32:15Z',
|
||||
metadata: {
|
||||
pages: 67,
|
||||
language: 'en',
|
||||
author: 'HR Department',
|
||||
created_date: '2024-01-01',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
filename: 'technical_specs_v3.docx',
|
||||
original_name: 'Technical Specifications v3.docx',
|
||||
file_type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
file_size: 1572864, // 1.5MB
|
||||
processing_status: 'processing',
|
||||
processing_progress: 67,
|
||||
dataset_id: 'ds_2',
|
||||
dataset_name: 'Technical Documentation',
|
||||
uploaded_at: '2024-01-15T11:15:00Z',
|
||||
metadata: {
|
||||
pages: 23,
|
||||
language: 'en',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
filename: 'market_research_q4.xlsx',
|
||||
original_name: 'Market Research Q4 2023.xlsx',
|
||||
file_type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
file_size: 512000, // 500KB
|
||||
processing_status: 'failed',
|
||||
error_message: 'Unsupported file format for text extraction',
|
||||
uploaded_at: '2024-01-15T09:45:00Z',
|
||||
metadata: {},
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
filename: 'project_proposal.txt',
|
||||
original_name: 'Project Proposal - AI Initiative.txt',
|
||||
file_type: 'text/plain',
|
||||
file_size: 25600, // 25KB
|
||||
processing_status: 'completed',
|
||||
chunk_count: 8,
|
||||
vector_count: 8,
|
||||
dataset_id: 'ds_3',
|
||||
dataset_name: 'Project Documents',
|
||||
uploaded_at: '2024-01-14T16:20:00Z',
|
||||
processed_at: '2024-01-14T16:21:30Z',
|
||||
metadata: {
|
||||
language: 'en',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
filename: 'meeting_notes_jan.md',
|
||||
original_name: 'Meeting Notes - January 2024.md',
|
||||
file_type: 'text/markdown',
|
||||
file_size: 12800, // 12.5KB
|
||||
processing_status: 'pending',
|
||||
uploaded_at: '2024-01-15T14:00:00Z',
|
||||
metadata: {},
|
||||
},
|
||||
];
|
||||
|
||||
const mockDatasets: RAGDataset[] = [
|
||||
{
|
||||
id: 'ds_1',
|
||||
name: 'Company Policies',
|
||||
description: 'HR policies, handbooks, and company guidelines',
|
||||
document_count: 12,
|
||||
chunk_count: 234,
|
||||
vector_count: 234,
|
||||
embedding_model: 'BAAI/bge-m3',
|
||||
created_at: '2024-01-10T09:00:00Z',
|
||||
updated_at: '2024-01-15T10:32:15Z',
|
||||
status: 'active',
|
||||
storage_size_mb: 15.7,
|
||||
},
|
||||
{
|
||||
id: 'ds_2',
|
||||
name: 'Technical Documentation',
|
||||
description: 'API docs, technical specifications, and architecture guides',
|
||||
document_count: 8,
|
||||
chunk_count: 156,
|
||||
vector_count: 156,
|
||||
embedding_model: 'BAAI/bge-m3',
|
||||
created_at: '2024-01-12T14:30:00Z',
|
||||
updated_at: '2024-01-15T11:15:00Z',
|
||||
status: 'processing',
|
||||
storage_size_mb: 8.2,
|
||||
},
|
||||
{
|
||||
id: 'ds_3',
|
||||
name: 'Project Documents',
|
||||
description: 'Project proposals, meeting notes, and planning documents',
|
||||
document_count: 5,
|
||||
chunk_count: 67,
|
||||
vector_count: 67,
|
||||
embedding_model: 'BAAI/bge-m3',
|
||||
created_at: '2024-01-08T11:00:00Z',
|
||||
updated_at: '2024-01-14T16:21:30Z',
|
||||
status: 'active',
|
||||
storage_size_mb: 4.1,
|
||||
},
|
||||
];
|
||||
|
||||
setDocuments(mockDocuments);
|
||||
setDatasets(mockDatasets);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter documents based on search and status
|
||||
const filteredDocuments = documents.filter(doc => {
|
||||
const matchesSearch = searchQuery === '' ||
|
||||
doc.original_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
doc.dataset_name?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesStatus = statusFilter === 'all' || doc.processing_status === statusFilter;
|
||||
const matchesDataset = selectedDataset === '' || doc.dataset_id === selectedDataset;
|
||||
|
||||
return matchesSearch && matchesStatus && matchesDataset;
|
||||
});
|
||||
|
||||
// File upload handling with real API
|
||||
const handleFileUpload = useCallback(async (files: FileList | File[]) => {
|
||||
setUploading(true);
|
||||
|
||||
// Convert FileList to Array
|
||||
const fileArray = Array.from(files);
|
||||
|
||||
try {
|
||||
// Upload files one by one
|
||||
for (const file of fileArray) {
|
||||
console.log('Uploading file:', file.name);
|
||||
|
||||
// Upload document
|
||||
const uploadResponse = await uploadDocument(file, {
|
||||
dataset_id: selectedDataset || undefined
|
||||
});
|
||||
|
||||
if (uploadResponse.data) {
|
||||
const uploadedDoc = uploadResponse.data;
|
||||
|
||||
// Add to documents list immediately
|
||||
const newDocument = {
|
||||
id: uploadedDoc.id,
|
||||
filename: uploadedDoc.filename,
|
||||
original_name: uploadedDoc.original_filename,
|
||||
file_type: uploadedDoc.file_type,
|
||||
file_size: uploadedDoc.file_size_bytes,
|
||||
processing_status: uploadedDoc.processing_status,
|
||||
chunk_count: uploadedDoc.chunk_count,
|
||||
vector_count: uploadedDoc.vector_count,
|
||||
dataset_id: undefined,
|
||||
dataset_name: 'Individual Document',
|
||||
uploaded_at: uploadedDoc.created_at,
|
||||
processed_at: uploadedDoc.processed_at,
|
||||
metadata: {}
|
||||
};
|
||||
|
||||
setDocuments(prev => [newDocument, ...prev]);
|
||||
|
||||
// Auto-process the document
|
||||
if (uploadedDoc.processing_status === 'pending') {
|
||||
try {
|
||||
await processDocument(uploadedDoc.id, 'hybrid');
|
||||
console.log(`Started processing document: ${file.name}`);
|
||||
|
||||
// Update document status
|
||||
setDocuments(prev => prev.map(doc =>
|
||||
doc.id === uploadedDoc.id
|
||||
? { ...doc, processing_status: 'processing' }
|
||||
: doc
|
||||
));
|
||||
} catch (processError) {
|
||||
console.error(`Failed to process document ${file.name}:`, processError);
|
||||
}
|
||||
}
|
||||
} else if (uploadResponse.error) {
|
||||
console.error(`Upload failed for ${file.name}:`, uploadResponse.error);
|
||||
// You could show a toast notification here
|
||||
}
|
||||
}
|
||||
|
||||
// Reload documents and datasets to get updated stats
|
||||
await loadDocumentsAndDatasets();
|
||||
|
||||
} catch (error) {
|
||||
console.error('File upload error:', error);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}, [selectedDataset]);
|
||||
|
||||
// Legacy simulation code kept as fallback
|
||||
const simulateFileUpload = (files: FileList | File[]) => {
|
||||
setUploading(true);
|
||||
|
||||
const fileArray = Array.from(files);
|
||||
|
||||
fileArray.forEach((file) => {
|
||||
|
||||
// Create a new document entry
|
||||
const newDocument: Document = {
|
||||
id: `upload_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
filename: file.name.replace(/[^a-zA-Z0-9.-]/g, '_'),
|
||||
original_name: file.name,
|
||||
file_type: file.type,
|
||||
file_size: file.size,
|
||||
processing_status: 'pending',
|
||||
uploaded_at: new Date().toISOString(),
|
||||
metadata: {},
|
||||
};
|
||||
|
||||
setDocuments(prev => [newDocument, ...prev]);
|
||||
|
||||
// Simulate processing delay
|
||||
setTimeout(() => {
|
||||
setDocuments(prev => prev.map(doc =>
|
||||
doc.id === newDocument.id
|
||||
? { ...doc, processing_status: 'processing', processing_progress: 0 }
|
||||
: doc
|
||||
));
|
||||
|
||||
// Simulate progress updates
|
||||
const progressInterval = setInterval(() => {
|
||||
setDocuments(prev => prev.map(doc => {
|
||||
if (doc.id === newDocument.id && doc.processing_progress !== undefined) {
|
||||
const newProgress = Math.min((doc.processing_progress || 0) + 15, 100);
|
||||
if (newProgress >= 100) {
|
||||
clearInterval(progressInterval);
|
||||
return {
|
||||
...doc,
|
||||
processing_status: 'completed',
|
||||
processing_progress: undefined,
|
||||
chunk_count: Math.floor(Math.random() * 20) + 5,
|
||||
vector_count: Math.floor(Math.random() * 20) + 5,
|
||||
processed_at: new Date().toISOString(),
|
||||
dataset_id: datasets[0]?.id,
|
||||
dataset_name: datasets[0]?.name,
|
||||
};
|
||||
}
|
||||
return { ...doc, processing_progress: newProgress };
|
||||
}
|
||||
return doc;
|
||||
}));
|
||||
}, 800);
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
setTimeout(() => setUploading(false), 1500);
|
||||
};
|
||||
|
||||
// File input change handler
|
||||
const handleFileInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = event.target.files;
|
||||
if (files && files.length > 0) {
|
||||
handleFileUpload(files);
|
||||
}
|
||||
};
|
||||
|
||||
// Drag and drop handlers
|
||||
const handleDragEnter = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(true);
|
||||
};
|
||||
|
||||
const handleDragLeave = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
};
|
||||
|
||||
const handleDragOver = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
};
|
||||
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setDragActive(false);
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files && files.length > 0) {
|
||||
handleFileUpload(files);
|
||||
}
|
||||
};
|
||||
|
||||
// Click handler for upload area
|
||||
const handleUploadClick = () => {
|
||||
fileInputRef.current?.click();
|
||||
};
|
||||
|
||||
const getFileIcon = (fileType: string) => {
|
||||
if (fileType.includes('pdf')) return <FileText className="h-5 w-5 text-red-600" />;
|
||||
if (fileType.includes('image')) return <FileImage className="h-5 w-5 text-green-600" />;
|
||||
if (fileType.includes('text') || fileType.includes('markdown')) return <FileText className="h-5 w-5 text-blue-600" />;
|
||||
if (fileType.includes('code') || fileType.includes('json')) return <FileCode className="h-5 w-5 text-purple-600" />;
|
||||
if (fileType.includes('zip') || fileType.includes('archive')) return <FileArchive className="h-5 w-5 text-orange-600" />;
|
||||
return <File className="h-5 w-5 text-gray-600" />;
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string, progress?: number) => {
|
||||
switch (status) {
|
||||
case 'completed':
|
||||
return (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
Processed
|
||||
</span>
|
||||
);
|
||||
case 'processing':
|
||||
return (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||
<RefreshCw className="h-3 w-3 mr-1 animate-spin" />
|
||||
Processing {progress ? `${progress}%` : ''}
|
||||
</span>
|
||||
);
|
||||
case 'pending':
|
||||
return (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||
<Clock className="h-3 w-3 mr-1" />
|
||||
Pending
|
||||
</span>
|
||||
);
|
||||
case 'failed':
|
||||
return (
|
||||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-red-100 text-red-800">
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
Failed
|
||||
</span>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||
};
|
||||
|
||||
|
||||
if (isLoading) {
|
||||
return <LoadingScreen />;
|
||||
}
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <div>Please log in to access documents.</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen flex bg-gt-gray-50">
|
||||
{/* Sidebar */}
|
||||
<Sidebar
|
||||
open={sidebarOpen}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
user={{ id: 1, email: "user@example.com" }}
|
||||
onMenuClick={() => {}}
|
||||
/>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col">
|
||||
{/* Header */}
|
||||
<Header
|
||||
user={user}
|
||||
onMenuClick={() => setSidebarOpen(true)}
|
||||
/>
|
||||
|
||||
{/* Documents Interface */}
|
||||
<main className="flex-1 bg-gt-white overflow-hidden">
|
||||
<div className="h-full flex flex-col p-6">
|
||||
{/* Page Header */}
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gt-gray-900">Document Management</h1>
|
||||
<p className="text-gt-gray-600 mt-1">
|
||||
Upload, process, and manage your documents for AI-powered search and analysis
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-3">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => setShowCreateDataset(true)}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Dataset
|
||||
</Button>
|
||||
<Button onClick={handleUploadClick}>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Upload Files
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept=".pdf,.doc,.docx,.txt,.md,.csv,.json"
|
||||
onChange={handleFileInputChange}
|
||||
className="hidden"
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stats Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white rounded-lg border border-gt-gray-200 p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<FileText className="h-6 w-6 text-blue-600" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium text-gt-gray-500">Total Documents</p>
|
||||
<p className="text-lg font-semibold text-gt-gray-900">{documents.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gt-gray-200 p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<Database className="h-6 w-6 text-green-600" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium text-gt-gray-500">RAG Datasets</p>
|
||||
<p className="text-lg font-semibold text-gt-gray-900">{datasets.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gt-gray-200 p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<Layers className="h-6 w-6 text-purple-600" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium text-gt-gray-500">Total Chunks</p>
|
||||
<p className="text-lg font-semibold text-gt-gray-900">
|
||||
{documents.reduce((sum, doc) => sum + (doc.chunk_count || 0), 0)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg border border-gt-gray-200 p-4">
|
||||
<div className="flex items-center">
|
||||
<div className="flex-shrink-0">
|
||||
<Brain className="h-6 w-6 text-orange-600" />
|
||||
</div>
|
||||
<div className="ml-3">
|
||||
<p className="text-sm font-medium text-gt-gray-500">Vector Embeddings</p>
|
||||
<p className="text-lg font-semibold text-gt-gray-900">
|
||||
{documents.reduce((sum, doc) => sum + (doc.vector_count || 0), 0)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Upload Area */}
|
||||
<div
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
onClick={handleUploadClick}
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer mb-6 ${
|
||||
dragActive
|
||||
? 'border-gt-green bg-gt-green/5'
|
||||
: 'border-gt-gray-300 hover:border-gt-green hover:bg-gt-gray-50'
|
||||
}`}
|
||||
>
|
||||
<Upload className="h-12 w-12 text-gt-gray-400 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gt-gray-900 mb-2">
|
||||
{dragActive ? 'Drop files here' : 'Upload Documents'}
|
||||
</h3>
|
||||
<p className="text-gt-gray-600 mb-4">
|
||||
Drag and drop files here, or click to select files
|
||||
</p>
|
||||
<p className="text-sm text-gt-gray-500">
|
||||
Supports PDF, DOC, DOCX, TXT, MD, CSV, and JSON files up to 10MB
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Filters and Search */}
|
||||
<div className="flex flex-col md:flex-row gap-4 mb-6">
|
||||
<div className="flex-1">
|
||||
<div className="relative">
|
||||
<Search className="h-5 w-5 text-gt-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search documents..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery((e as React.ChangeEvent<HTMLInputElement>).target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-gt-gray-300 rounded-lg focus:ring-2 focus:ring-gt-green focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter((e as React.ChangeEvent<HTMLSelectElement>).target.value)}
|
||||
className="px-3 py-2 border border-gt-gray-300 rounded-lg focus:ring-2 focus:ring-gt-green focus:border-transparent"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="completed">Processed</option>
|
||||
<option value="processing">Processing</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="failed">Failed</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
value={selectedDataset}
|
||||
onChange={(e) => setSelectedDataset((e as React.ChangeEvent<HTMLSelectElement>).target.value)}
|
||||
className="px-3 py-2 border border-gt-gray-300 rounded-lg focus:ring-2 focus:ring-gt-green focus:border-transparent"
|
||||
>
|
||||
<option value="">All Datasets</option>
|
||||
{datasets.map(dataset => (
|
||||
<option key={dataset.id} value={dataset.id}>{dataset.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* RAG Datasets Overview */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-gt-gray-900 mb-3">RAG Datasets</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
{datasets.map(dataset => (
|
||||
<div key={dataset.id} className="bg-white rounded-lg border border-gt-gray-200 p-4">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<h3 className="font-medium text-gt-gray-900">{dataset.name}</h3>
|
||||
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${
|
||||
dataset.status === 'active' ? 'bg-green-100 text-green-800' :
|
||||
dataset.status === 'processing' ? 'bg-blue-100 text-blue-800' :
|
||||
'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{dataset.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-gt-gray-600 mb-3">{dataset.description}</p>
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-gt-gray-500">Documents:</span>
|
||||
<span className="ml-1 font-medium">{dataset.document_count}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gt-gray-500">Chunks:</span>
|
||||
<span className="ml-1 font-medium">{dataset.chunk_count}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gt-gray-500">Vectors:</span>
|
||||
<span className="ml-1 font-medium">{dataset.vector_count}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-gt-gray-500">Size:</span>
|
||||
<span className="ml-1 font-medium">{dataset.storage_size_mb.toFixed(1)} MB</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Documents List */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<div className="bg-white rounded-lg border border-gt-gray-200 h-full flex flex-col">
|
||||
<div className="px-6 py-4 border-b border-gt-gray-200">
|
||||
<h2 className="text-lg font-semibold text-gt-gray-900">
|
||||
Documents ({filteredDocuments.length})
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{loading ? (
|
||||
<div className="p-6 space-y-4">
|
||||
{[...Array(5)].map((_, i) => (
|
||||
<div key={i} className="animate-pulse">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="w-10 h-10 bg-gt-gray-200 rounded"></div>
|
||||
<div className="flex-1">
|
||||
<div className="w-1/2 h-4 bg-gt-gray-200 rounded mb-2"></div>
|
||||
<div className="w-1/4 h-3 bg-gt-gray-200 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filteredDocuments.length === 0 ? (
|
||||
<div className="p-12 text-center">
|
||||
<FolderOpen className="h-12 w-12 text-gt-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gt-gray-900 mb-2">No documents found</h3>
|
||||
<p className="text-gt-gray-600">
|
||||
Upload your first document to get started with AI-powered document search.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y divide-gt-gray-200">
|
||||
{filteredDocuments.map(document => (
|
||||
<div key={document.id} className="p-6 hover:bg-gt-gray-50 transition-colors">
|
||||
<div className="flex items-start space-x-4">
|
||||
<div className="flex-shrink-0">
|
||||
{getFileIcon(document.file_type)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex-1">
|
||||
<h3 className="text-sm font-medium text-gt-gray-900 truncate">
|
||||
{document.original_name}
|
||||
</h3>
|
||||
<div className="mt-1 flex items-center space-x-4 text-sm text-gt-gray-500">
|
||||
<span>{formatFileSize(document.file_size)}</span>
|
||||
<span>•</span>
|
||||
<span>{formatDateTime(document.uploaded_at)}</span>
|
||||
{document.dataset_name && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span className="text-gt-green font-medium">{document.dataset_name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{document.processing_status === 'completed' && (
|
||||
<div className="mt-2 flex items-center space-x-4 text-sm text-gt-gray-600">
|
||||
<span className="flex items-center">
|
||||
<Layers className="h-3 w-3 mr-1" />
|
||||
{document.chunk_count} chunks
|
||||
</span>
|
||||
<span className="flex items-center">
|
||||
<Brain className="h-3 w-3 mr-1" />
|
||||
{document.vector_count} vectors
|
||||
</span>
|
||||
{document.metadata.pages && (
|
||||
<span className="flex items-center">
|
||||
<FileText className="h-3 w-3 mr-1" />
|
||||
{document.metadata.pages} pages
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{document.error_message && (
|
||||
<div className="mt-2 text-sm text-red-600">
|
||||
{document.error_message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 ml-4">
|
||||
{getStatusBadge(document.processing_status, document.processing_progress)}
|
||||
|
||||
<div className="flex items-center space-x-1">
|
||||
<Button variant="ghost" size="sm">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Download className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
{/* Mobile Sidebar Overlay */}
|
||||
{sidebarOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-40 lg:hidden"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function DocumentsPage() {
|
||||
return (
|
||||
<AuthGuard requiredCapabilities={[GT2_CAPABILITIES.DOCUMENTS_READ]}>
|
||||
<DocumentsPageContent />
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
246
apps/tenant-app/src/app/globals.css
Normal file
246
apps/tenant-app/src/app/globals.css
Normal file
@@ -0,0 +1,246 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* GT 2.0 Custom Styles */
|
||||
@layer base {
|
||||
:root {
|
||||
--gt-white: #ffffff;
|
||||
--gt-black: #000000;
|
||||
--gt-green: #00d084;
|
||||
--gt-gray-50: #fafbfc;
|
||||
--gt-gray-100: #f4f6f8;
|
||||
--gt-gray-200: #e8ecef;
|
||||
--gt-gray-300: #d1d9e0;
|
||||
--gt-gray-400: #9aa5b1;
|
||||
--gt-gray-500: #677489;
|
||||
--gt-gray-600: #4a5568;
|
||||
--gt-gray-700: #2d3748;
|
||||
--gt-gray-800: #1a202c;
|
||||
--gt-gray-900: #171923;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'SF Pro Display', Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-gt-white text-gt-gray-900 antialiased;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Chat Interface Styles */
|
||||
.chat-container {
|
||||
@apply grid grid-rows-[auto_1fr_auto] h-screen bg-gt-white;
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
@apply flex-1 overflow-y-auto px-4 py-6 space-y-4;
|
||||
}
|
||||
|
||||
.message-user {
|
||||
@apply bg-gt-gray-100 text-gt-gray-900 ml-12 rounded-2xl rounded-br-sm px-4 py-3;
|
||||
}
|
||||
|
||||
|
||||
.message-system {
|
||||
@apply bg-gt-gray-50 text-gt-gray-700 mx-8 rounded-lg px-3 py-2 text-sm text-center;
|
||||
}
|
||||
|
||||
/* Input Styles */
|
||||
.chat-input {
|
||||
@apply bg-gt-white border-2 border-gt-gray-200 rounded-xl px-4 py-3 transition-colors duration-150 resize-none;
|
||||
}
|
||||
|
||||
.chat-input:focus {
|
||||
@apply outline-none border-gt-green shadow-[0_0_0_3px_rgba(0,208,132,0.1)];
|
||||
}
|
||||
|
||||
/* Button Styles */
|
||||
.btn-primary {
|
||||
@apply bg-gt-green text-gt-white font-medium px-4 py-2 rounded-lg transition-all duration-150 hover:bg-opacity-90 hover:-translate-y-0.5 hover:shadow-md;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gt-white text-gt-gray-700 font-medium px-4 py-2 rounded-lg border border-gt-gray-300 transition-all duration-150 hover:bg-gt-gray-50;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
@apply bg-transparent text-gt-gray-600 font-medium px-4 py-2 rounded-lg transition-all duration-150 hover:bg-gt-gray-50;
|
||||
}
|
||||
|
||||
/* Status Indicators */
|
||||
.status-indicator {
|
||||
@apply w-2 h-2 rounded-full;
|
||||
}
|
||||
|
||||
.status-online {
|
||||
@apply bg-gt-green;
|
||||
}
|
||||
|
||||
.status-offline {
|
||||
@apply bg-gt-gray-400;
|
||||
}
|
||||
|
||||
.status-error {
|
||||
@apply bg-red-500;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
.typing-indicator {
|
||||
@apply flex items-center gap-1;
|
||||
}
|
||||
|
||||
.typing-dot {
|
||||
@apply w-1.5 h-1.5 bg-gt-green rounded-full animate-neural-pulse;
|
||||
}
|
||||
|
||||
/* Chat Animation Classes */
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.5s ease-in;
|
||||
}
|
||||
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
/* Enhanced Message Styles */
|
||||
.message-agent {
|
||||
@apply bg-gradient-to-r from-gt-green to-green-600 text-white mr-12 rounded-2xl rounded-bl-sm px-4 py-3 shadow-lg;
|
||||
}
|
||||
|
||||
.message-agent:hover {
|
||||
@apply shadow-xl transform-gpu;
|
||||
}
|
||||
|
||||
/* Code Block Improvements */
|
||||
.hljs {
|
||||
background: #1a202c !important;
|
||||
color: #e2e8f0 !important;
|
||||
}
|
||||
|
||||
/* Scrollbar Styling */
|
||||
.chat-messages::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.chat-messages::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.chat-messages::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 208, 132, 0.3);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.chat-messages::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 208, 132, 0.5);
|
||||
}
|
||||
|
||||
.typing-dot:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
|
||||
.typing-dot:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
|
||||
/* Document Upload */
|
||||
.upload-zone {
|
||||
@apply border-2 border-dashed border-gt-gray-300 rounded-lg p-8 text-center bg-gt-gray-25 transition-all duration-200;
|
||||
}
|
||||
|
||||
.upload-zone.dragover {
|
||||
@apply border-gt-green bg-gt-green bg-opacity-5;
|
||||
}
|
||||
|
||||
/* Loading States */
|
||||
.loading-skeleton {
|
||||
@apply bg-gt-gray-200 animate-pulse-gentle rounded;
|
||||
}
|
||||
|
||||
/* Security Indicators */
|
||||
.security-badge {
|
||||
@apply inline-flex items-center px-2 py-1 rounded-sm text-xs font-medium;
|
||||
}
|
||||
|
||||
.security-badge.secure {
|
||||
@apply bg-green-100 text-green-700 border border-green-300;
|
||||
}
|
||||
|
||||
.security-badge.warning {
|
||||
@apply bg-yellow-100 text-yellow-700 border border-yellow-300;
|
||||
}
|
||||
|
||||
.security-badge.error {
|
||||
@apply bg-red-100 text-red-700 border border-red-300;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Custom Select - Hide Native Arrow */
|
||||
.select-no-arrow {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
background-image: none;
|
||||
}
|
||||
|
||||
.select-no-arrow::-ms-expand {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* Custom Scrollbar */
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--gt-gray-300) var(--gt-gray-100);
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: var(--gt-gray-100);
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: var(--gt-gray-300);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--gt-gray-400);
|
||||
}
|
||||
|
||||
/* Text Utilities */
|
||||
.text-balance {
|
||||
text-wrap: balance;
|
||||
}
|
||||
|
||||
/* Animation Utilities */
|
||||
.animate-fade-in {
|
||||
animation: fadeIn 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.animate-slide-up {
|
||||
animation: slideUp 0.3s ease-out;
|
||||
}
|
||||
}
|
||||
10
apps/tenant-app/src/app/health/route.ts
Normal file
10
apps/tenant-app/src/app/health/route.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
status: 'healthy',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'gt2-tenant-app',
|
||||
version: '1.0.0'
|
||||
});
|
||||
}
|
||||
19
apps/tenant-app/src/app/home/page.tsx
Normal file
19
apps/tenant-app/src/app/home/page.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
/**
|
||||
* Home page redirect
|
||||
*
|
||||
* This page has been removed. Users are redirected to the agents page.
|
||||
*/
|
||||
export default function HomePage() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
router.replace('/agents');
|
||||
}, [router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
74
apps/tenant-app/src/app/layout.tsx
Normal file
74
apps/tenant-app/src/app/layout.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import type { Metadata, Viewport } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import { Providers } from '@/lib/providers';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
// Viewport must be exported separately in Next.js 14+
|
||||
export const viewport: Viewport = {
|
||||
width: 'device-width',
|
||||
initialScale: 1,
|
||||
};
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: {
|
||||
template: 'GT AI OS | %s',
|
||||
default: 'GT AI OS'
|
||||
},
|
||||
description: 'Your intelligent AI agent for enterprise workflows and decision-making',
|
||||
keywords: ['AI', 'agent', 'enterprise', 'chat', 'documents', 'productivity'],
|
||||
authors: [{ name: 'GT Edge AI' }],
|
||||
robots: 'noindex, nofollow', // Tenant apps should not be indexed
|
||||
manifest: '/manifest.json',
|
||||
icons: {
|
||||
icon: '/favicon.png',
|
||||
shortcut: '/favicon.png',
|
||||
apple: '/gt-logo.png'
|
||||
},
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: 'default',
|
||||
title: 'GT AI OS'
|
||||
}
|
||||
};
|
||||
|
||||
interface RootLayoutProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: RootLayoutProps) {
|
||||
return (
|
||||
<html lang="en" className="h-full">
|
||||
<head>
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="format-detection" content="telephone=no" />
|
||||
<link rel="icon" href="/gt-small-logo.png" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||
{/* Console log suppression - controlled by NEXT_PUBLIC_DISABLE_CONSOLE_LOGS */}
|
||||
{process.env.NEXT_PUBLIC_DISABLE_CONSOLE_LOGS === 'true' && (
|
||||
<script
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: `
|
||||
(function() {
|
||||
// Override console methods to suppress logs
|
||||
// Keep error and warn for critical issues
|
||||
console.log = function() {};
|
||||
console.debug = function() {};
|
||||
console.info = function() {};
|
||||
})();
|
||||
`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</head>
|
||||
<body className={`${inter.className} h-full antialiased`}>
|
||||
<Providers>
|
||||
<div className="flex flex-col h-full bg-gt-white text-gt-gray-900">
|
||||
{children}
|
||||
</div>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
102
apps/tenant-app/src/app/login/login-page-client.tsx
Normal file
102
apps/tenant-app/src/app/login/login-page-client.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { LoginForm } from '@/components/auth/login-form';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
|
||||
interface LoginPageClientProps {
|
||||
tenantName: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Session expiration type for different messages (Issue #242)
|
||||
* - 'idle': 30-minute inactivity timeout
|
||||
* - 'absolute': 8-hour session limit reached
|
||||
*/
|
||||
type SessionExpiredType = 'idle' | 'absolute' | null;
|
||||
|
||||
/**
|
||||
* Client Component - Handles auth checks and redirects
|
||||
* Receives tenant name from Server Component (no flash)
|
||||
*/
|
||||
export function LoginPageClient({ tenantName }: LoginPageClientProps) {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const { isAuthenticated, checkAuth } = useAuthStore();
|
||||
const [sessionExpiredType, setSessionExpiredType] = useState<SessionExpiredType>(null);
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'GT AI OS | Login';
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Check for session expiration parameter (NIST/OWASP Issue #242)
|
||||
const sessionExpiredParam = searchParams.get('session_expired');
|
||||
if (sessionExpiredParam === 'true') {
|
||||
// Idle timeout (30 min inactivity)
|
||||
setSessionExpiredType('idle');
|
||||
} else if (sessionExpiredParam === 'absolute') {
|
||||
// Absolute timeout (8 hour session limit)
|
||||
setSessionExpiredType('absolute');
|
||||
}
|
||||
|
||||
// Clean up the URL by removing the query parameter (after a delay to show the message)
|
||||
if (sessionExpiredParam) {
|
||||
setTimeout(() => {
|
||||
router.replace('/login');
|
||||
}, 100);
|
||||
}
|
||||
}, [searchParams, router]);
|
||||
|
||||
useEffect(() => {
|
||||
// Check authentication status on mount to sync localStorage with store
|
||||
checkAuth();
|
||||
}, [checkAuth]);
|
||||
|
||||
useEffect(() => {
|
||||
// Don't auto-redirect if we just completed TFA (prevents race condition flash)
|
||||
if (sessionStorage.getItem('gt2_tfa_verified')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Redirect to agents if already authenticated (prevents redirect loop)
|
||||
if (isAuthenticated) {
|
||||
router.push('/agents');
|
||||
}
|
||||
}, [isAuthenticated, router]);
|
||||
|
||||
// Get the appropriate message based on expiration type (Issue #242)
|
||||
const getSessionExpiredMessage = (): string => {
|
||||
if (sessionExpiredType === 'absolute') {
|
||||
return 'Your session has reached the maximum duration (8 hours). Please log in again.';
|
||||
}
|
||||
return 'Your session has expired due to inactivity. Please log in again.';
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{sessionExpiredType && (
|
||||
<div className="fixed top-4 left-1/2 transform -translate-x-1/2 z-50 animate-in fade-in slide-in-from-top-2">
|
||||
<div className="bg-red-50 border border-red-200 text-red-800 px-6 py-3 rounded-lg shadow-lg flex items-center gap-3">
|
||||
<svg
|
||||
className="w-5 h-5 text-red-600"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<span className="font-medium">{getSessionExpiredMessage()}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<LoginForm tenantName={tenantName} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
51
apps/tenant-app/src/app/login/page.tsx
Normal file
51
apps/tenant-app/src/app/login/page.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import Image from 'next/image';
|
||||
import { LoginPageClient } from './login-page-client';
|
||||
|
||||
// Force dynamic rendering - this page needs runtime data
|
||||
export const dynamic = 'force-dynamic';
|
||||
|
||||
/**
|
||||
* Server Component - Fetches tenant name before rendering
|
||||
* This eliminates the flash/delay when displaying tenant name
|
||||
*/
|
||||
async function getTenantName(): Promise<string> {
|
||||
try {
|
||||
const controlPanelUrl = process.env.CONTROL_PANEL_URL || 'http://control-panel-backend:8000';
|
||||
const tenantDomain = process.env.TENANT_DOMAIN || 'test-company';
|
||||
|
||||
const response = await fetch(
|
||||
`${controlPanelUrl}/api/v1/tenant-info?tenant_domain=${tenantDomain}`,
|
||||
{
|
||||
cache: 'no-store',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
}
|
||||
);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
return data.name || '';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tenant name on server:', error);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export default async function LoginPage() {
|
||||
// Fetch tenant name on server before rendering
|
||||
const tenantName = await getTenantName();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gt-gray-50 to-gt-gray-100 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-grid-pattern opacity-5"></div>
|
||||
<div className="relative z-10">
|
||||
<LoginPageClient tenantName={tenantName} />
|
||||
|
||||
<div className="text-center mt-8 text-sm text-gt-gray-500 space-y-2">
|
||||
<p className="text-xs">GT AI OS Community | v2.0.33</p>
|
||||
<p>© 2025 GT Edge AI. All rights reserved.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
apps/tenant-app/src/app/observability/page.tsx
Normal file
31
apps/tenant-app/src/app/observability/page.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
'use client';
|
||||
|
||||
import { AppLayout } from '@/components/layout/app-layout';
|
||||
import { AuthGuard } from '@/components/auth/auth-guard';
|
||||
import { usePageTitle } from '@/hooks/use-page-title';
|
||||
import { ObservabilityDashboard } from '@/components/observability/observability-dashboard';
|
||||
|
||||
/**
|
||||
* Observability Dashboard Page
|
||||
* Available to all authenticated users with role-based data filtering:
|
||||
* - Admins/Developers: See all platform activity with user filtering
|
||||
* - Analysts/Students: See only their personal activity
|
||||
*
|
||||
* Features:
|
||||
* - Overview metrics (conversations, messages, tokens, users)
|
||||
* - Time series charts for usage trends
|
||||
* - Breakdown by user, agent, and model
|
||||
* - Full conversation browser with content viewing
|
||||
* - CSV/JSON export functionality
|
||||
*/
|
||||
export default function ObservabilityPage() {
|
||||
usePageTitle('Observability');
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<AppLayout>
|
||||
<ObservabilityDashboard />
|
||||
</AppLayout>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
51
apps/tenant-app/src/app/page.tsx
Normal file
51
apps/tenant-app/src/app/page.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { isTokenValid } from '@/services/auth';
|
||||
import { LoadingScreen } from '@/components/ui/loading-screen';
|
||||
|
||||
/**
|
||||
* Root page with authentication-aware redirect
|
||||
*
|
||||
* Checks authentication state and redirects appropriately:
|
||||
* - Authenticated users go to /agents (home page)
|
||||
* - Unauthenticated users go to /login
|
||||
*
|
||||
* This prevents the redirect loop that causes login page flickering.
|
||||
*/
|
||||
export default function RootPage() {
|
||||
const router = useRouter();
|
||||
const [isChecking, setIsChecking] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const checkAuthAndRedirect = () => {
|
||||
try {
|
||||
// Check if user is authenticated
|
||||
if (isTokenValid()) {
|
||||
// Authenticated - go to agents (home page)
|
||||
router.replace('/agents');
|
||||
} else {
|
||||
// Not authenticated - go to login
|
||||
router.replace('/login');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error);
|
||||
// On error, redirect to login for safety
|
||||
router.replace('/login');
|
||||
} finally {
|
||||
setIsChecking(false);
|
||||
}
|
||||
};
|
||||
|
||||
checkAuthAndRedirect();
|
||||
}, [router]);
|
||||
|
||||
// Show loading screen while checking authentication
|
||||
if (isChecking) {
|
||||
return <LoadingScreen message="Loading GT 2.0..." />;
|
||||
}
|
||||
|
||||
// Fallback - should not be visible due to redirects
|
||||
return null;
|
||||
}
|
||||
80
apps/tenant-app/src/app/settings/page.tsx
Normal file
80
apps/tenant-app/src/app/settings/page.tsx
Normal file
@@ -0,0 +1,80 @@
|
||||
'use client';
|
||||
|
||||
import { AppLayout } from '@/components/layout/app-layout';
|
||||
import { AuthGuard } from '@/components/auth/auth-guard';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { TFASettings } from '@/components/settings/tfa-settings';
|
||||
import { User } from 'lucide-react';
|
||||
import { usePageTitle } from '@/hooks/use-page-title';
|
||||
|
||||
export default function SettingsPage() {
|
||||
usePageTitle('Settings');
|
||||
const { user } = useAuthStore();
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<AppLayout>
|
||||
<div className="min-h-screen bg-gradient-to-br from-gt-gray-50 to-gt-gray-100">
|
||||
<div className="max-w-4xl mx-auto px-4 py-8 space-y-6">
|
||||
{/* Header */}
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold text-gt-gray-900">Account Settings</h1>
|
||||
<p className="text-gt-gray-600 mt-2">
|
||||
Manage your account preferences and security settings
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Profile Information */}
|
||||
<div className="bg-white border border-gt-gray-200 rounded-lg p-6 space-y-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-16 h-16 bg-gt-green rounded-full flex items-center justify-center">
|
||||
<User className="w-8 h-8 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gt-gray-900">
|
||||
{user?.full_name || 'User'}
|
||||
</h2>
|
||||
<p className="text-sm text-gt-gray-600">{user?.email}</p>
|
||||
{user?.user_type && (
|
||||
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gt-blue-100 text-gt-blue-800 mt-1">
|
||||
{user.user_type.replace('_', ' ').toUpperCase()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security Section */}
|
||||
<div className="space-y-4">
|
||||
<div className="border-b border-gt-gray-200 pb-2">
|
||||
<h2 className="text-xl font-semibold text-gt-gray-900">Security</h2>
|
||||
<p className="text-sm text-gt-gray-600 mt-1">
|
||||
Manage your account security and authentication methods
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* TFA Settings Component */}
|
||||
<TFASettings />
|
||||
</div>
|
||||
|
||||
{/* Additional Settings Sections (Placeholder for future) */}
|
||||
{/*
|
||||
<div className="space-y-4">
|
||||
<div className="border-b border-gt-gray-200 pb-2">
|
||||
<h2 className="text-xl font-semibold text-gt-gray-900">Preferences</h2>
|
||||
<p className="text-sm text-gt-gray-600 mt-1">
|
||||
Customize your GT Edge AI experience
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white border border-gt-gray-200 rounded-lg p-6">
|
||||
<p className="text-sm text-gt-gray-500">Additional preferences coming soon...</p>
|
||||
</div>
|
||||
</div>
|
||||
*/}
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
303
apps/tenant-app/src/app/teams/page.tsx
Normal file
303
apps/tenant-app/src/app/teams/page.tsx
Normal file
@@ -0,0 +1,303 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Plus, Users, Search } from 'lucide-react';
|
||||
import { AppLayout } from '@/components/layout/app-layout';
|
||||
import { AuthGuard } from '@/components/auth/auth-guard';
|
||||
import { GT2_CAPABILITIES } from '@/lib/capabilities';
|
||||
import {
|
||||
TeamCard,
|
||||
TeamCreateModal,
|
||||
TeamEditModal,
|
||||
DeleteTeamDialog,
|
||||
LeaveTeamDialog,
|
||||
TeamManagementPanel,
|
||||
InvitationPanel,
|
||||
ObservableRequestPanel,
|
||||
type CreateTeamData,
|
||||
type UpdateTeamData
|
||||
} from '@/components/teams';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { usePageTitle } from '@/hooks/use-page-title';
|
||||
import {
|
||||
useTeams,
|
||||
useCreateTeam,
|
||||
useUpdateTeam,
|
||||
useDeleteTeam,
|
||||
useRemoveTeamMember
|
||||
} from '@/hooks/use-teams';
|
||||
import type { Team } from '@/services';
|
||||
import { getAuthToken, parseTokenPayload } from '@/services/auth';
|
||||
|
||||
function TeamsPageContent() {
|
||||
usePageTitle('Teams');
|
||||
|
||||
// Search state
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// React Query hooks
|
||||
const { data: teams = [], isLoading: loading } = useTeams();
|
||||
const createTeam = useCreateTeam();
|
||||
const updateTeam = useUpdateTeam();
|
||||
const deleteTeam = useDeleteTeam();
|
||||
const removeTeamMember = useRemoveTeamMember();
|
||||
|
||||
// Modal states
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showLeaveDialog, setShowLeaveDialog] = useState(false);
|
||||
const [showManagementPanel, setShowManagementPanel] = useState(false);
|
||||
const [selectedTeam, setSelectedTeam] = useState<Team | null>(null);
|
||||
|
||||
// Filter teams by search query
|
||||
const filteredTeams = teams.filter(team =>
|
||||
team.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
team.description?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
// Calculate stats
|
||||
const ownedTeams = teams.filter(t => t.is_owner).length;
|
||||
const memberTeams = teams.filter(t => !t.is_owner).length;
|
||||
|
||||
// Team action handlers
|
||||
const handleCreateTeam = async (data: CreateTeamData) => {
|
||||
try {
|
||||
await createTeam.mutateAsync(data);
|
||||
console.log('Team created successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to create team:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditTeam = (teamId: string) => {
|
||||
const team = teams.find(t => t.id === teamId);
|
||||
if (team) {
|
||||
setSelectedTeam(team);
|
||||
setShowEditModal(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateTeam = async (teamId: string, data: UpdateTeamData) => {
|
||||
try {
|
||||
await updateTeam.mutateAsync({ teamId, data });
|
||||
console.log('Team updated successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to update team:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteTeam = (teamId: string) => {
|
||||
const team = teams.find(t => t.id === teamId);
|
||||
if (team) {
|
||||
setSelectedTeam(team);
|
||||
setShowDeleteDialog(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async (teamId: string) => {
|
||||
try {
|
||||
await deleteTeam.mutateAsync(teamId);
|
||||
console.log('Team deleted successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to delete team:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleManageTeam = (teamId: string) => {
|
||||
const team = teams.find(t => t.id === teamId);
|
||||
if (team) {
|
||||
setSelectedTeam(team);
|
||||
setShowManagementPanel(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleLeaveTeam = (teamId: string) => {
|
||||
const team = teams.find(t => t.id === teamId);
|
||||
if (team) {
|
||||
setSelectedTeam(team);
|
||||
setShowLeaveDialog(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleConfirmLeave = async (teamId: string) => {
|
||||
try {
|
||||
// Get user ID from JWT token
|
||||
const token = getAuthToken();
|
||||
if (!token) {
|
||||
console.error('No auth token found');
|
||||
alert('Authentication required. Please log in again.');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = parseTokenPayload(token);
|
||||
if (!payload?.sub) {
|
||||
console.error('User ID not found in token');
|
||||
alert('Invalid authentication. Please log in again.');
|
||||
return;
|
||||
}
|
||||
|
||||
// sub contains the user ID from the JWT
|
||||
await removeTeamMember.mutateAsync({ teamId, userId: payload.sub });
|
||||
console.log('Successfully left team');
|
||||
} catch (error: any) {
|
||||
console.error('Failed to leave team:', error);
|
||||
alert(`Failed to leave team: ${error.message || 'An error occurred'}`);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-3">
|
||||
<Users className="w-8 h-8 text-gt-green" />
|
||||
Teams
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Collaborate and share resources with your team
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="bg-gt-green hover:bg-gt-green/90"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Team
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="flex items-center gap-6 text-sm">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-600">Total:</span>
|
||||
<span className="font-semibold text-gray-900">{teams.length}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-600">Owned:</span>
|
||||
<span className="font-semibold text-gt-green">{ownedTeams}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-gray-600">Member of:</span>
|
||||
<span className="font-semibold text-blue-600">{memberTeams}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Pending Invitations */}
|
||||
<InvitationPanel />
|
||||
|
||||
{/* Observable Requests */}
|
||||
<ObservableRequestPanel />
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-4">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-gray-400 w-5 h-5 z-10" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="Search teams..."
|
||||
value={searchQuery}
|
||||
onChange={(value) => setSearchQuery(value)}
|
||||
className="pl-10"
|
||||
clearable
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Teams List */}
|
||||
{loading ? (
|
||||
<div className="bg-white rounded-lg shadow-sm border p-12 text-center">
|
||||
<div className="inline-block h-8 w-8 animate-spin rounded-full border-4 border-solid border-gt-green border-r-transparent"></div>
|
||||
<p className="text-gray-600 mt-4">Loading teams...</p>
|
||||
</div>
|
||||
) : filteredTeams.length === 0 ? (
|
||||
<div className="bg-white rounded-lg shadow-sm border p-12 text-center">
|
||||
<Users className="w-16 h-16 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
||||
{searchQuery ? 'No teams found' : 'No teams yet'}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-6">
|
||||
{searchQuery
|
||||
? 'Try adjusting your search query'
|
||||
: 'Create your first team to start collaborating'}
|
||||
</p>
|
||||
{!searchQuery && (
|
||||
<Button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="bg-gt-green hover:bg-gt-green/90"
|
||||
>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Team
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredTeams.map(team => (
|
||||
<TeamCard
|
||||
key={team.id}
|
||||
team={team}
|
||||
onManage={handleManageTeam}
|
||||
onEdit={handleEditTeam}
|
||||
onDelete={handleDeleteTeam}
|
||||
onLeave={handleLeaveTeam}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modals */}
|
||||
<TeamCreateModal
|
||||
open={showCreateModal}
|
||||
onOpenChange={setShowCreateModal}
|
||||
onCreateTeam={handleCreateTeam}
|
||||
loading={createTeam.isPending}
|
||||
/>
|
||||
|
||||
<TeamEditModal
|
||||
open={showEditModal}
|
||||
team={selectedTeam}
|
||||
onOpenChange={setShowEditModal}
|
||||
onUpdateTeam={handleUpdateTeam}
|
||||
loading={updateTeam.isPending}
|
||||
/>
|
||||
|
||||
<DeleteTeamDialog
|
||||
open={showDeleteDialog}
|
||||
team={selectedTeam}
|
||||
onOpenChange={setShowDeleteDialog}
|
||||
onConfirm={handleConfirmDelete}
|
||||
loading={deleteTeam.isPending}
|
||||
/>
|
||||
|
||||
<LeaveTeamDialog
|
||||
open={showLeaveDialog}
|
||||
team={selectedTeam}
|
||||
onOpenChange={setShowLeaveDialog}
|
||||
onConfirm={handleConfirmLeave}
|
||||
loading={removeTeamMember.isPending}
|
||||
/>
|
||||
|
||||
<TeamManagementPanel
|
||||
open={showManagementPanel}
|
||||
team={selectedTeam}
|
||||
onOpenChange={setShowManagementPanel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function TeamsPage() {
|
||||
return (
|
||||
<AuthGuard requiredCapabilities={[GT2_CAPABILITIES.DATASETS_READ]}>
|
||||
<AppLayout>
|
||||
<TeamsPageContent />
|
||||
</AppLayout>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
115
apps/tenant-app/src/app/test-agents/page.tsx
Normal file
115
apps/tenant-app/src/app/test-agents/page.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
'use client';
|
||||
|
||||
import { TestLayout } from '@/components/layout/test-layout';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Plus, Play, Settings, Brain, Code, Shield } from 'lucide-react';
|
||||
import { mockApi } from '@/lib/mock-api';
|
||||
import { formatDateOnly } from '@/lib/utils';
|
||||
|
||||
export default function TestAgentsPage() {
|
||||
const [agents, setAgents] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadAgents();
|
||||
}, []);
|
||||
|
||||
const loadAgents = async () => {
|
||||
try {
|
||||
const data = await mockApi.agents.list();
|
||||
setAgents(data.agents);
|
||||
} catch (error) {
|
||||
console.error('Failed to load agents:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getAgentIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'research': return <Brain className="w-5 h-5" />;
|
||||
case 'coding': return <Code className="w-5 h-5" />;
|
||||
case 'security': return <Shield className="w-5 h-5" />;
|
||||
default: return <Brain className="w-5 h-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TestLayout>
|
||||
<div className="p-6">
|
||||
<div className="mb-6 flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">AI Agents</h1>
|
||||
<p className="text-gray-600 mt-1">Manage your autonomous AI agents</p>
|
||||
</div>
|
||||
<Button className="bg-green-600 hover:bg-green-700 text-white">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
Create Agent
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{agents.map((agent) => (
|
||||
<Card key={agent.id} className="p-6 hover:shadow-lg transition-shadow">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
<div className="p-2 bg-green-100 rounded-lg mr-3">
|
||||
{getAgentIcon(agent.agent_type)}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">{agent.name}</h3>
|
||||
<Badge variant="secondary" className="mt-1">
|
||||
{agent.agent_type}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<Badge
|
||||
className={`${
|
||||
agent.status === 'idle' ? 'bg-gray-100 text-gray-700' : 'bg-green-100 text-green-700'
|
||||
}`}
|
||||
>
|
||||
{agent.status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-gray-600 mb-4">{agent.description}</p>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{agent.capabilities.slice(0, 3).map((cap: string, idx: number) => (
|
||||
<Badge key={idx} variant="secondary" className="text-xs">
|
||||
{cap.replace('_', ' ')}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-sm text-gray-500 mb-4">
|
||||
<span>Executions: {agent.execution_count}</span>
|
||||
<span>Last run: {formatDateOnly(agent.last_execution)}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button className="flex-1" variant="secondary">
|
||||
<Settings className="w-4 h-4 mr-2" />
|
||||
Configure
|
||||
</Button>
|
||||
<Button className="flex-1 bg-green-600 hover:bg-green-700 text-white">
|
||||
<Play className="w-4 h-4 mr-2" />
|
||||
Execute
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TestLayout>
|
||||
);
|
||||
}
|
||||
163
apps/tenant-app/src/app/test-documents/page.tsx
Normal file
163
apps/tenant-app/src/app/test-documents/page.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
'use client';
|
||||
|
||||
import { TestLayout } from '@/components/layout/test-layout';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Upload, FileText, FileCheck, FileX, Download, Trash2, Search } from 'lucide-react';
|
||||
import { mockApi } from '@/lib/mock-api';
|
||||
import { formatDateOnly } from '@/lib/utils';
|
||||
|
||||
export default function TestDocumentsPage() {
|
||||
const [documents, setDocuments] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [storageUsed, setStorageUsed] = useState(0);
|
||||
const [storageLimit, setStorageLimit] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
loadDocuments();
|
||||
}, []);
|
||||
|
||||
const loadDocuments = async () => {
|
||||
try {
|
||||
const data = await mockApi.documents.list();
|
||||
setDocuments(data.documents);
|
||||
setStorageUsed(data.storage_used);
|
||||
setStorageLimit(data.storage_limit);
|
||||
} catch (error) {
|
||||
console.error('Failed to load documents:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const formatFileSize = (bytes: number) => {
|
||||
const mb = bytes / (1024 * 1024);
|
||||
if (mb < 1) return `${(bytes / 1024).toFixed(2)} KB`;
|
||||
return `${mb.toFixed(2)} MB`;
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'completed': return <FileCheck className="w-4 h-4 text-green-600" />;
|
||||
case 'processing': return <div className="w-4 h-4 border-2 border-blue-600 border-t-transparent rounded-full animate-spin" />;
|
||||
case 'failed': return <FileX className="w-4 h-4 text-red-600" />;
|
||||
default: return <FileText className="w-4 h-4 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
const colors = {
|
||||
completed: 'bg-green-100 text-green-700',
|
||||
processing: 'bg-blue-100 text-blue-700',
|
||||
failed: 'bg-red-100 text-red-700',
|
||||
pending: 'bg-gray-100 text-gray-700'
|
||||
};
|
||||
return colors[status as keyof typeof colors] || colors.pending;
|
||||
};
|
||||
|
||||
const storagePercentage = (storageUsed / storageLimit) * 100;
|
||||
|
||||
return (
|
||||
<TestLayout>
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Documents</h1>
|
||||
<p className="text-gray-600 mt-1">Upload and manage your knowledge base</p>
|
||||
</div>
|
||||
<Button className="bg-green-600 hover:bg-green-700 text-white">
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
Upload Document
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Storage Usage */}
|
||||
<Card className="p-4">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-sm font-medium text-gray-700">Storage Usage</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
{formatFileSize(storageUsed)} / {formatFileSize(storageLimit)}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={storagePercentage} className="h-2" />
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Search Bar */}
|
||||
<div className="mb-6">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search documents..."
|
||||
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-green-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Documents List */}
|
||||
{loading ? (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{documents.map((doc) => (
|
||||
<Card key={doc.id} className="p-4 hover:shadow-md transition-shadow">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="p-2 bg-gray-100 rounded-lg">
|
||||
<FileText className="w-6 h-6 text-gray-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="font-medium text-gray-900">{doc.filename}</h3>
|
||||
<div className="flex items-center space-x-4 mt-1">
|
||||
<span className="text-sm text-gray-500">{formatFileSize(doc.file_size)}</span>
|
||||
<span className="text-sm text-gray-500">•</span>
|
||||
<span className="text-sm text-gray-500">{doc.chunk_count} chunks</span>
|
||||
<span className="text-sm text-gray-500">•</span>
|
||||
<span className="text-sm text-gray-500">
|
||||
Uploaded {formatDateOnly(doc.created_at)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className="flex items-center space-x-2">
|
||||
{getStatusIcon(doc.processing_status)}
|
||||
<Badge className={getStatusBadge(doc.processing_status)}>
|
||||
{doc.processing_status}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="secondary" size="sm">
|
||||
<Download className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" className="text-red-600 hover:bg-red-50">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{doc.processing_status === 'processing' && (
|
||||
<div className="mt-3">
|
||||
<Progress value={65} className="h-1" />
|
||||
<p className="text-xs text-gray-500 mt-1">Processing document...</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TestLayout>
|
||||
);
|
||||
}
|
||||
233
apps/tenant-app/src/app/test-games/page.tsx
Normal file
233
apps/tenant-app/src/app/test-games/page.tsx
Normal file
@@ -0,0 +1,233 @@
|
||||
'use client';
|
||||
|
||||
import { TestLayout } from '@/components/layout/test-layout';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Trophy, Brain, Puzzle, Users, ChevronRight, Star, Target, TrendingUp } from 'lucide-react';
|
||||
import { mockApi } from '@/lib/mock-api';
|
||||
|
||||
export default function TestGamesPage() {
|
||||
const [games, setGames] = useState<any[]>([]);
|
||||
const [progress, setProgress] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadGamesAndProgress();
|
||||
}, []);
|
||||
|
||||
const loadGamesAndProgress = async () => {
|
||||
try {
|
||||
const [gamesData, progressData] = await Promise.all([
|
||||
mockApi.games.list(),
|
||||
mockApi.games.getProgress()
|
||||
]);
|
||||
setGames(gamesData.games);
|
||||
setProgress(progressData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load games:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getGameIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'chess': return '♟️';
|
||||
case 'logic_puzzle': return '🧩';
|
||||
case 'philosophical_dilemma': return '🤔';
|
||||
default: return '🎮';
|
||||
}
|
||||
};
|
||||
|
||||
const getDifficultyColor = (level: string) => {
|
||||
switch (level) {
|
||||
case 'beginner':
|
||||
case 'easy': return 'bg-green-100 text-green-700';
|
||||
case 'intermediate':
|
||||
case 'medium': return 'bg-yellow-100 text-yellow-700';
|
||||
case 'expert':
|
||||
case 'hard': return 'bg-red-100 text-red-700';
|
||||
default: return 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<TestLayout>
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
|
||||
</div>
|
||||
</TestLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TestLayout>
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<h1 className="text-2xl font-bold text-gray-900">AI Literacy & Cognitive Development</h1>
|
||||
<p className="text-gray-600 mt-1">Develop critical thinking skills through games and challenges</p>
|
||||
</div>
|
||||
|
||||
{/* Progress Overview */}
|
||||
{progress && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Trophy className="w-5 h-5 text-yellow-500" />
|
||||
<span className="text-2xl font-bold">{progress.overall_progress.level}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Current Level</p>
|
||||
<Progress value={(progress.overall_progress.experience / progress.overall_progress.next_level_xp) * 100} className="mt-2 h-1" />
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
{progress.overall_progress.experience} / {progress.overall_progress.next_level_xp} XP
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Brain className="w-5 h-5 text-purple-500" />
|
||||
<span className="text-2xl font-bold">{progress.skill_metrics.strategic_thinking}%</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Strategic Thinking</p>
|
||||
<Progress value={progress.skill_metrics.strategic_thinking} className="mt-2 h-1" />
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<Target className="w-5 h-5 text-blue-500" />
|
||||
<span className="text-2xl font-bold">{progress.skill_metrics.logical_reasoning}%</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Logical Reasoning</p>
|
||||
<Progress value={progress.skill_metrics.logical_reasoning} className="mt-2 h-1" />
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<TrendingUp className="w-5 h-5 text-green-500" />
|
||||
<span className="text-2xl font-bold">{progress.learning_streak}</span>
|
||||
</div>
|
||||
<p className="text-sm text-gray-600">Day Streak</p>
|
||||
<div className="flex mt-2">
|
||||
{[...Array(7)].map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className={`w-4 h-4 rounded-sm mr-1 ${
|
||||
i < progress.learning_streak ? 'bg-green-500' : 'bg-gray-200'
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Skills Overview */}
|
||||
{progress && (
|
||||
<Card className="p-6 mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Skill Development</h2>
|
||||
<div className="space-y-3">
|
||||
{Object.entries(progress.skill_metrics).map(([skill, value]: [string, any]) => (
|
||||
<div key={skill}>
|
||||
<div className="flex justify-between mb-1">
|
||||
<span className="text-sm font-medium text-gray-700">
|
||||
{skill.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}
|
||||
</span>
|
||||
<span className="text-sm text-gray-500">{value}%</span>
|
||||
</div>
|
||||
<Progress value={value} className="h-2" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Games Grid */}
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Available Games & Challenges</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{games.map((game) => (
|
||||
<Card key={game.id} className="p-6 hover:shadow-lg transition-shadow cursor-pointer">
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="text-4xl">{getGameIcon(game.type)}</div>
|
||||
{game.user_rating && (
|
||||
<Badge variant="secondary" className="flex items-center">
|
||||
<Star className="w-3 h-3 mr-1 fill-yellow-500 text-yellow-500" />
|
||||
{game.user_rating}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<h3 className="font-semibold text-gray-900 mb-2">{game.name}</h3>
|
||||
<p className="text-sm text-gray-600 mb-4">{game.description}</p>
|
||||
|
||||
{/* Difficulty Levels */}
|
||||
{game.difficulty_levels && (
|
||||
<div className="flex flex-wrap gap-2 mb-4">
|
||||
{game.difficulty_levels.map((level: string) => (
|
||||
<Badge key={level} className={getDifficultyColor(level)}>
|
||||
{level}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stats */}
|
||||
<div className="space-y-2 mb-4">
|
||||
{game.games_played !== undefined && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Games Played:</span>
|
||||
<span className="font-medium">{game.games_played}</span>
|
||||
</div>
|
||||
)}
|
||||
{game.win_rate !== undefined && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Win Rate:</span>
|
||||
<span className="font-medium">{(game.win_rate * 100).toFixed(0)}%</span>
|
||||
</div>
|
||||
)}
|
||||
{game.puzzles_solved !== undefined && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Puzzles Solved:</span>
|
||||
<span className="font-medium">{game.puzzles_solved}</span>
|
||||
</div>
|
||||
)}
|
||||
{game.scenarios_completed !== undefined && (
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-gray-500">Scenarios:</span>
|
||||
<span className="font-medium">{game.scenarios_completed}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button className="w-full bg-green-600 hover:bg-green-700 text-white">
|
||||
Play Now
|
||||
<ChevronRight className="w-4 h-4 ml-2" />
|
||||
</Button>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recommendations */}
|
||||
{progress?.recommendations && progress.recommendations.length > 0 && (
|
||||
<Card className="p-6">
|
||||
<h2 className="text-lg font-semibold mb-4">Personalized Recommendations</h2>
|
||||
<div className="space-y-3">
|
||||
{progress.recommendations.map((rec: string, idx: number) => (
|
||||
<div key={idx} className="flex items-start">
|
||||
<div className="w-2 h-2 rounded-full bg-green-500 mt-1.5 mr-3 flex-shrink-0" />
|
||||
<p className="text-sm text-gray-700">{rec}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</TestLayout>
|
||||
);
|
||||
}
|
||||
250
apps/tenant-app/src/app/test-projects/page.tsx
Normal file
250
apps/tenant-app/src/app/test-projects/page.tsx
Normal file
@@ -0,0 +1,250 @@
|
||||
'use client';
|
||||
|
||||
import { TestLayout } from '@/components/layout/test-layout';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
Plus, Folder, FolderOpen, Users, Calendar,
|
||||
BarChart3, CheckCircle2, Clock, AlertCircle,
|
||||
MoreVertical, Share2, Archive
|
||||
} from 'lucide-react';
|
||||
import { mockApi } from '@/lib/mock-api';
|
||||
import { formatDateOnly } from '@/lib/utils';
|
||||
|
||||
export default function TestProjectsPage() {
|
||||
const [projects, setProjects] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadProjects();
|
||||
}, []);
|
||||
|
||||
const loadProjects = async () => {
|
||||
try {
|
||||
const data = await mockApi.projects.list();
|
||||
setProjects(data.projects);
|
||||
} catch (error) {
|
||||
console.error('Failed to load projects:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active': return <Clock className="w-4 h-4 text-blue-600" />;
|
||||
case 'completed': return <CheckCircle2 className="w-4 h-4 text-green-600" />;
|
||||
case 'on_hold': return <AlertCircle className="w-4 h-4 text-yellow-600" />;
|
||||
default: return <Folder className="w-4 h-4 text-gray-400" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active': return 'bg-blue-100 text-blue-700';
|
||||
case 'completed': return 'bg-green-100 text-green-700';
|
||||
case 'on_hold': return 'bg-yellow-100 text-yellow-700';
|
||||
default: return 'bg-gray-100 text-gray-700';
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<TestLayout>
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-green-600"></div>
|
||||
</div>
|
||||
</TestLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TestLayout>
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Projects</h1>
|
||||
<p className="text-gray-600 mt-1">Manage your research and analysis projects</p>
|
||||
</div>
|
||||
<Button className="bg-green-600 hover:bg-green-700 text-white">
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
New Project
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Project Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Active Projects</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{projects.filter(p => p.status === 'active').length}
|
||||
</p>
|
||||
</div>
|
||||
<Clock className="w-8 h-8 text-blue-500 opacity-50" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Completed</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{projects.filter(p => p.status === 'completed').length}
|
||||
</p>
|
||||
</div>
|
||||
<CheckCircle2 className="w-8 h-8 text-green-500 opacity-50" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Total Hours</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{projects.reduce((acc, p) => acc + (p.time_invested_minutes || 0), 0) / 60}h
|
||||
</p>
|
||||
</div>
|
||||
<BarChart3 className="w-8 h-8 text-purple-500 opacity-50" />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card className="p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-gray-600">Collaborators</p>
|
||||
<p className="text-2xl font-bold text-gray-900">
|
||||
{projects.reduce((acc, p) => acc + (p.collaborators?.length || 0), 0)}
|
||||
</p>
|
||||
</div>
|
||||
<Users className="w-8 h-8 text-orange-500 opacity-50" />
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Projects Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{projects.map((project) => (
|
||||
<Card key={project.id} className="hover:shadow-lg transition-shadow">
|
||||
<div className="p-6">
|
||||
{/* Project Header */}
|
||||
<div className="flex items-start justify-between mb-4">
|
||||
<div className="flex items-center">
|
||||
{project.status === 'active' ? (
|
||||
<FolderOpen className="w-5 h-5 text-green-600 mr-2" />
|
||||
) : (
|
||||
<Folder className="w-5 h-5 text-gray-400 mr-2" />
|
||||
)}
|
||||
<h3 className="font-semibold text-gray-900">{project.name}</h3>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0">
|
||||
<MoreVertical className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<p className="text-sm text-gray-600 mb-4 line-clamp-2">
|
||||
{project.description}
|
||||
</p>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between text-sm mb-1">
|
||||
<span className="text-gray-600">Progress</span>
|
||||
<span className="font-medium">{project.completion_percentage}%</span>
|
||||
</div>
|
||||
<Progress value={project.completion_percentage} className="h-2" />
|
||||
</div>
|
||||
|
||||
{/* Status and Type */}
|
||||
<div className="flex items-center gap-2 mb-4">
|
||||
<Badge className={getStatusColor(project.status)}>
|
||||
{project.status.replace('_', ' ')}
|
||||
</Badge>
|
||||
<Badge variant="secondary">
|
||||
{project.project_type}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Resources */}
|
||||
{project.linked_resources && project.linked_resources.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="text-xs text-gray-500 mb-2">Resources:</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{project.linked_resources.slice(0, 3).map((resource: string, idx: number) => (
|
||||
<Badge key={idx} variant="secondary" className="text-xs">
|
||||
{resource}
|
||||
</Badge>
|
||||
))}
|
||||
{project.linked_resources.length > 3 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
+{project.linked_resources.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Collaborators */}
|
||||
{project.collaborators && project.collaborators.length > 0 && (
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex -space-x-2">
|
||||
{project.collaborators.slice(0, 3).map((collaborator: any, idx: number) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="w-8 h-8 rounded-full bg-gray-300 border-2 border-white flex items-center justify-center"
|
||||
>
|
||||
<span className="text-xs font-medium text-gray-600">
|
||||
{collaborator.name.split(' ').map((n: string) => n[0]).join('')}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{project.collaborators.length > 3 && (
|
||||
<div className="w-8 h-8 rounded-full bg-gray-200 border-2 border-white flex items-center justify-center">
|
||||
<span className="text-xs font-medium text-gray-600">
|
||||
+{project.collaborators.length - 3}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Share2 className="w-4 h-4 text-gray-400" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dates */}
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 mb-4">
|
||||
<div className="flex items-center">
|
||||
<Calendar className="w-3 h-3 mr-1" />
|
||||
Created {formatDateOnly(project.created_at)}
|
||||
</div>
|
||||
{project.last_activity && (
|
||||
<span>Active {formatDateOnly(project.last_activity)}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2">
|
||||
<Button className="flex-1" variant="secondary">
|
||||
Open Project
|
||||
</Button>
|
||||
{project.status === 'active' && (
|
||||
<Button variant="ghost" size="sm" className="px-2">
|
||||
<Archive className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</TestLayout>
|
||||
);
|
||||
}
|
||||
258
apps/tenant-app/src/app/test-settings/page.tsx
Normal file
258
apps/tenant-app/src/app/test-settings/page.tsx
Normal file
@@ -0,0 +1,258 @@
|
||||
'use client';
|
||||
|
||||
import { TestLayout } from '@/components/layout/test-layout';
|
||||
import { useState } from 'react';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
User, Bell, Shield, Palette, Globe, Database,
|
||||
CreditCard, HelpCircle, ChevronRight, Save, Moon, Sun
|
||||
} from 'lucide-react';
|
||||
|
||||
export default function TestSettingsPage() {
|
||||
const [theme, setTheme] = useState('system');
|
||||
const [notifications, setNotifications] = useState({
|
||||
email: true,
|
||||
push: true,
|
||||
sms: false,
|
||||
mentions: true,
|
||||
updates: false,
|
||||
});
|
||||
const [privacy, setPrivacy] = useState({
|
||||
analytics: true,
|
||||
progressSharing: false,
|
||||
peerComparison: false,
|
||||
});
|
||||
|
||||
const settingsSections = [
|
||||
{
|
||||
title: 'Profile',
|
||||
icon: User,
|
||||
items: [
|
||||
{ label: 'Name', value: 'Jane User' },
|
||||
{ label: 'Email', value: 'jane@test-company.com' },
|
||||
{ label: 'Role', value: 'Tenant User', badge: true },
|
||||
{ label: 'Department', value: 'Research & Development' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Appearance',
|
||||
icon: Palette,
|
||||
items: [
|
||||
{ label: 'Theme', component: 'theme-selector' },
|
||||
{ label: 'UI Density', value: 'Comfortable' },
|
||||
{ label: 'Accent Color', value: '#00d084', color: true },
|
||||
{ label: 'Font Size', value: 'Medium' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Notifications',
|
||||
icon: Bell,
|
||||
items: [
|
||||
{ label: 'Email Notifications', toggle: 'email' },
|
||||
{ label: 'Push Notifications', toggle: 'push' },
|
||||
{ label: 'SMS Alerts', toggle: 'sms' },
|
||||
{ label: 'Mentions', toggle: 'mentions' },
|
||||
{ label: 'Product Updates', toggle: 'updates' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Privacy & Security',
|
||||
icon: Shield,
|
||||
items: [
|
||||
{ label: 'Two-Factor Authentication', value: 'Enabled', badge: 'green' },
|
||||
{ label: 'Session Timeout', value: '30 minutes' },
|
||||
{ label: 'Usage Analytics', toggle: 'analytics' },
|
||||
{ label: 'Progress Sharing', toggle: 'progressSharing' },
|
||||
{ label: 'Peer Comparison', toggle: 'peerComparison' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'AI Preferences',
|
||||
icon: Globe,
|
||||
items: [
|
||||
{ label: 'Default Model', value: 'GPT-4' },
|
||||
{ label: 'Temperature', value: '0.7' },
|
||||
{ label: 'Max Tokens', value: '2000' },
|
||||
{ label: 'Explanation Level', value: 'Intermediate' },
|
||||
{ label: 'Auto-suggestions', value: 'Enabled', badge: 'green' },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Storage & Usage',
|
||||
icon: Database,
|
||||
items: [
|
||||
{ label: 'Storage Used', value: '3.6 GB / 10 GB', progress: 36 },
|
||||
{ label: 'API Calls', value: '12,456 / 50,000', progress: 25 },
|
||||
{ label: 'Compute Hours', value: '45 / 100', progress: 45 },
|
||||
]
|
||||
},
|
||||
{
|
||||
title: 'Billing',
|
||||
icon: CreditCard,
|
||||
items: [
|
||||
{ label: 'Current Plan', value: 'Professional', badge: 'blue' },
|
||||
{ label: 'Billing Cycle', value: 'Monthly' },
|
||||
{ label: 'Next Payment', value: 'Feb 1, 2024' },
|
||||
{ label: 'Payment Method', value: '•••• 4242', action: true },
|
||||
]
|
||||
},
|
||||
];
|
||||
|
||||
const handleToggle = (section: string, key: string) => {
|
||||
if (section === 'notifications') {
|
||||
setNotifications(prev => ({ ...prev, [key]: !prev[key as keyof typeof prev] }));
|
||||
} else if (section === 'privacy') {
|
||||
setPrivacy(prev => ({ ...prev, [key]: !prev[key as keyof typeof prev] }));
|
||||
}
|
||||
};
|
||||
|
||||
const getToggleValue = (section: string, key: string) => {
|
||||
if (section === 'notifications') return notifications[key as keyof typeof notifications];
|
||||
if (section === 'privacy') return privacy[key as keyof typeof privacy];
|
||||
return false;
|
||||
};
|
||||
|
||||
return (
|
||||
<TestLayout>
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
|
||||
<p className="text-gray-600 mt-1">Manage your account and preferences</p>
|
||||
</div>
|
||||
<Button className="bg-green-600 hover:bg-green-700 text-white">
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Settings Sections */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{settingsSections.map((section) => {
|
||||
const Icon = section.icon;
|
||||
return (
|
||||
<Card key={section.title} className="p-6">
|
||||
<div className="flex items-center mb-4">
|
||||
<Icon className="w-5 h-5 text-green-600 mr-2" />
|
||||
<h2 className="text-lg font-semibold text-gray-900">{section.title}</h2>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{section.items.map((item, idx) => (
|
||||
<div key={idx} className="flex items-center justify-between">
|
||||
<span className="text-sm text-gray-600">{item.label}</span>
|
||||
|
||||
{'component' in item && item.component === 'theme-selector' ? (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={theme === 'light' ? 'primary' : 'secondary'}
|
||||
onClick={() => setTheme('light')}
|
||||
className="h-8 px-3"
|
||||
>
|
||||
<Sun className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={theme === 'dark' ? 'primary' : 'secondary'}
|
||||
onClick={() => setTheme('dark')}
|
||||
className="h-8 px-3"
|
||||
>
|
||||
<Moon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={theme === 'system' ? 'primary' : 'secondary'}
|
||||
onClick={() => setTheme('system')}
|
||||
className="h-8 px-3"
|
||||
>
|
||||
Auto
|
||||
</Button>
|
||||
</div>
|
||||
) : 'toggle' in item && item.toggle ? (
|
||||
<Switch
|
||||
checked={getToggleValue(
|
||||
section.title.toLowerCase().includes('notification') ? 'notifications' :
|
||||
section.title.toLowerCase().includes('privacy') ? 'privacy' : '',
|
||||
item.toggle
|
||||
)}
|
||||
onCheckedChange={() => handleToggle(
|
||||
section.title.toLowerCase().includes('notification') ? 'notifications' :
|
||||
section.title.toLowerCase().includes('privacy') ? 'privacy' : '',
|
||||
item.toggle
|
||||
)}
|
||||
/>
|
||||
) : 'badge' in item && item.badge ? (
|
||||
<Badge
|
||||
className={
|
||||
item.badge === 'green' ? 'bg-green-100 text-green-700' :
|
||||
item.badge === 'blue' ? 'bg-blue-100 text-blue-700' :
|
||||
item.badge === true ? 'bg-gray-100 text-gray-700' : ''
|
||||
}
|
||||
>
|
||||
{item.value}
|
||||
</Badge>
|
||||
) : 'color' in item && item.color ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="w-6 h-6 rounded border border-gray-300"
|
||||
style={{ backgroundColor: item.value }}
|
||||
/>
|
||||
<span className="text-sm font-medium">{item.value}</span>
|
||||
</div>
|
||||
) : 'progress' in item && item.progress !== undefined ? (
|
||||
<div className="flex items-center gap-3 flex-1 max-w-xs ml-4">
|
||||
<span className="text-sm font-medium text-gray-900">{item.value}</span>
|
||||
<div className="flex-1 bg-gray-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-green-600 h-2 rounded-full"
|
||||
style={{ width: `${item.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : 'action' in item && item.action ? (
|
||||
<Button variant="ghost" size="sm" className="h-8">
|
||||
<span className="text-sm mr-1">{item.value}</span>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-sm font-medium text-gray-900">{item.value}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Help Section */}
|
||||
<Card className="mt-6 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center">
|
||||
<HelpCircle className="w-5 h-5 text-green-600 mr-3" />
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900">Need Help?</h3>
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
Access documentation, tutorials, and contact support
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary">View Documentation</Button>
|
||||
<Button className="bg-green-600 hover:bg-green-700 text-white">
|
||||
Contact Support
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
</TestLayout>
|
||||
);
|
||||
}
|
||||
364
apps/tenant-app/src/app/verify-tfa/page.tsx
Normal file
364
apps/tenant-app/src/app/verify-tfa/page.tsx
Normal file
@@ -0,0 +1,364 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore, useHasHydrated } from '@/stores/auth-store';
|
||||
import { verifyTFALogin, getTFASessionData, getTFAQRCodeBlob } from '@/services/tfa';
|
||||
import { parseCapabilities, setAuthToken, setUser, parseTokenPayload, mapControlPanelRoleToTenantRole } from '@/services/auth';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
|
||||
export default function VerifyTFAPage() {
|
||||
const router = useRouter();
|
||||
const hasHydrated = useHasHydrated();
|
||||
const {
|
||||
requiresTfa,
|
||||
tfaConfigured,
|
||||
logout,
|
||||
} = useAuthStore();
|
||||
|
||||
const [code, setCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isFetchingSession, setIsFetchingSession] = useState(true);
|
||||
const [attempts, setAttempts] = useState(0);
|
||||
|
||||
// Session data fetched from server
|
||||
const [qrCodeBlobUrl, setQrCodeBlobUrl] = useState<string | null>(null);
|
||||
const [manualEntryKey, setManualEntryKey] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Wait for hydration before checking TFA state
|
||||
if (!hasHydrated) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Fetch TFA session data from server using HTTP-only cookie
|
||||
const fetchSessionData = async () => {
|
||||
if (!requiresTfa) {
|
||||
// User doesn't need TFA, redirect to login
|
||||
router.push('/login');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsFetchingSession(true);
|
||||
|
||||
// Fetch session metadata
|
||||
const sessionData = await getTFASessionData();
|
||||
|
||||
if (sessionData.manual_entry_key) {
|
||||
setManualEntryKey(sessionData.manual_entry_key);
|
||||
}
|
||||
|
||||
// Fetch QR code as secure blob (if needed for setup)
|
||||
if (!sessionData.tfa_configured) {
|
||||
const blobUrl = await getTFAQRCodeBlob();
|
||||
setQrCodeBlobUrl(blobUrl);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch TFA session data:', err);
|
||||
setError('Session expired. Please login again.');
|
||||
setTimeout(() => {
|
||||
router.push('/login');
|
||||
}, 2000);
|
||||
} finally {
|
||||
setIsFetchingSession(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSessionData();
|
||||
|
||||
// Cleanup: revoke blob URL on unmount
|
||||
return () => {
|
||||
if (qrCodeBlobUrl) {
|
||||
URL.revokeObjectURL(qrCodeBlobUrl);
|
||||
}
|
||||
};
|
||||
}, [hasHydrated, requiresTfa, router]);
|
||||
|
||||
const handleVerify = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
// Validate code format (6 digits)
|
||||
if (!/^\d{6}$/.test(code)) {
|
||||
setError('Please enter a valid 6-digit code');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Session cookie automatically sent with request
|
||||
const result = await verifyTFALogin(code);
|
||||
|
||||
if (result.success && result.access_token) {
|
||||
// Save token
|
||||
setAuthToken(result.access_token);
|
||||
|
||||
// Decode JWT to extract user data
|
||||
const payload = parseTokenPayload(result.access_token);
|
||||
|
||||
if (!payload) {
|
||||
throw new Error('Failed to decode token');
|
||||
}
|
||||
|
||||
// Construct user object from JWT claims
|
||||
const user = {
|
||||
id: parseInt(payload.sub),
|
||||
email: payload.email,
|
||||
full_name: payload.current_tenant?.display_name || payload.email,
|
||||
role: mapControlPanelRoleToTenantRole(payload.user_type),
|
||||
user_type: payload.user_type,
|
||||
tenant_id: payload.current_tenant?.id ? parseInt(payload.current_tenant.id) : null,
|
||||
is_active: true,
|
||||
available_tenants: payload.available_tenants || []
|
||||
};
|
||||
|
||||
// Parse capabilities from JWT
|
||||
const capabilityStrings = parseCapabilities(result.access_token);
|
||||
|
||||
// Save user to localStorage
|
||||
setUser(user);
|
||||
|
||||
// Update auth store
|
||||
useAuthStore.setState({
|
||||
token: result.access_token,
|
||||
user: user,
|
||||
capabilities: capabilityStrings,
|
||||
isAuthenticated: true,
|
||||
requiresTfa: false,
|
||||
tfaConfigured: false,
|
||||
isLoading: false,
|
||||
});
|
||||
|
||||
// Small delay ensures localStorage writes complete before navigation
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
// Signal that TFA was just completed (prevents login page flash)
|
||||
sessionStorage.setItem('gt2_tfa_verified', 'true');
|
||||
|
||||
// Use replace to skip login page in browser history
|
||||
router.replace('/agents');
|
||||
} else {
|
||||
throw new Error(result.message || 'Verification failed');
|
||||
}
|
||||
} catch (err: any) {
|
||||
const newAttempts = attempts + 1;
|
||||
setAttempts(newAttempts);
|
||||
|
||||
if (newAttempts >= 5) {
|
||||
setError('Too many attempts. Please wait 60 seconds and try again.');
|
||||
} else {
|
||||
setError(err.message || 'Invalid verification code. Please try again.');
|
||||
}
|
||||
|
||||
setCode('');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
// Only show cancel if NOT mandatory (tfa_configured=true means optional)
|
||||
logout();
|
||||
router.push('/login');
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
};
|
||||
|
||||
// Show loading while hydrating or fetching session data
|
||||
if (!hasHydrated || isFetchingSession) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gt-gray-50 to-gt-gray-100 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto w-16 h-16 bg-gt-green rounded-full flex items-center justify-center mb-4">
|
||||
<svg className="animate-spin h-8 w-8 text-white" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
</div>
|
||||
<p className="text-gt-gray-600">Loading TFA setup...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!requiresTfa) {
|
||||
return null; // Will redirect via useEffect
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-gt-gray-50 to-gt-gray-100 flex items-center justify-center p-4">
|
||||
<div className="absolute inset-0 bg-grid-pattern opacity-5"></div>
|
||||
|
||||
<div className="relative z-10 w-full max-w-md">
|
||||
<div className="text-center mb-8">
|
||||
<div className="mx-auto w-16 h-16 bg-gt-green rounded-full flex items-center justify-center mb-4">
|
||||
<svg
|
||||
className="w-8 h-8 text-white"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold text-gt-gray-900 mb-2">
|
||||
{tfaConfigured ? 'Two-Factor Authentication' : 'Setup Two-Factor Authentication'}
|
||||
</h1>
|
||||
<p className="text-gt-gray-600">
|
||||
{tfaConfigured
|
||||
? 'Enter the 6-digit code from your authenticator app'
|
||||
: 'Your administrator requires 2FA for your account'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-lg p-8 border border-gt-gray-200">
|
||||
{/* Mode A: Setup (tfa_configured=false) */}
|
||||
{!tfaConfigured && qrCodeBlobUrl && (
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-gt-gray-900 mb-4">
|
||||
Scan QR Code
|
||||
</h2>
|
||||
|
||||
{/* QR Code Display (secure blob URL - TOTP secret never in JavaScript) */}
|
||||
<div className="bg-white p-4 rounded-lg border-2 border-gt-gray-200 mb-4 flex justify-center">
|
||||
<img
|
||||
src={qrCodeBlobUrl}
|
||||
alt="QR Code"
|
||||
className="w-48 h-48"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Manual Entry Key */}
|
||||
{manualEntryKey && (
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gt-gray-700 mb-2">
|
||||
Manual Entry Key
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-3 py-2 bg-gt-gray-50 border border-gt-gray-200 rounded-lg text-sm font-mono">
|
||||
{manualEntryKey}
|
||||
</code>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(manualEntryKey.replace(/\s/g, ''))}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-gt-blue-50 border border-gt-blue-200 rounded-lg p-4 mb-4">
|
||||
<p className="text-sm text-gt-blue-900">
|
||||
<strong>Instructions:</strong>
|
||||
</p>
|
||||
<ol className="text-sm text-gt-blue-800 mt-2 ml-4 list-decimal space-y-1">
|
||||
<li>Download Google Authenticator or any TOTP app</li>
|
||||
<li>Scan the QR code or enter the manual key as shown above</li>
|
||||
<li>Enter the 6-digit code below to complete setup</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Code Input (both modes) */}
|
||||
<form onSubmit={handleVerify} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gt-gray-700 mb-2">
|
||||
6-Digit Code
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(value) => setCode(value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
disabled={isLoading || attempts >= 5}
|
||||
className="text-center text-2xl tracking-widest font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-3">
|
||||
<div className="flex items-center">
|
||||
<svg
|
||||
className="w-4 h-4 text-red-600 mr-2"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
<p className="text-sm text-red-700">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attempts > 0 && attempts < 5 && (
|
||||
<p className="text-sm text-gt-gray-600 text-center">
|
||||
Attempts remaining: {5 - attempts}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
loading={isLoading}
|
||||
disabled={isLoading || code.length !== 6 || attempts >= 5}
|
||||
className="flex-1"
|
||||
>
|
||||
{isLoading ? 'Verifying...' : tfaConfigured ? 'Verify' : 'Verify and Complete Setup'}
|
||||
</Button>
|
||||
|
||||
{/* Only show cancel if TFA is already configured (optional flow) */}
|
||||
{tfaConfigured && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="lg"
|
||||
onClick={handleCancel}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* No cancel button for mandatory setup (Mode A) */}
|
||||
{!tfaConfigured && (
|
||||
<div className="mt-4 text-center">
|
||||
<p className="text-xs text-gt-gray-500">
|
||||
2FA is required for your account. Contact your administrator if you need assistance.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Security Info */}
|
||||
<div className="mt-6 text-center text-sm text-gt-gray-500">
|
||||
<p>Secured by GT Edge AI • Enterprise Grade Security</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
785
apps/tenant-app/src/app/workflows/page.tsx
Normal file
785
apps/tenant-app/src/app/workflows/page.tsx
Normal file
@@ -0,0 +1,785 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { EnhancedWorkflowCanvas } from '@/components/workflow/EnhancedWorkflowCanvas';
|
||||
import { WorkflowChatInterface } from '@/components/workflow/WorkflowChatInterface';
|
||||
import { WorkflowButtonInterface } from '@/components/workflow/WorkflowButtonInterface';
|
||||
import { WorkflowFormInterface } from '@/components/workflow/WorkflowFormInterface';
|
||||
import { WorkflowExecutionView } from '@/components/workflow/WorkflowExecutionView';
|
||||
import { AppLayout } from '@/components/layout/app-layout';
|
||||
import { AuthGuard } from '@/components/auth/auth-guard';
|
||||
import { GT2_CAPABILITIES } from '@/lib/capabilities';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
Play,
|
||||
Pause,
|
||||
Settings,
|
||||
MoreHorizontal,
|
||||
Bot,
|
||||
Zap,
|
||||
Clock,
|
||||
DollarSign,
|
||||
Activity,
|
||||
Edit,
|
||||
Copy,
|
||||
Trash2,
|
||||
Eye,
|
||||
MessageSquare,
|
||||
Square,
|
||||
BarChart3,
|
||||
Workflow
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface Workflow {
|
||||
id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
status: 'draft' | 'active' | 'paused' | 'archived';
|
||||
definition: {
|
||||
nodes: any[];
|
||||
edges: any[];
|
||||
config?: Record<string, any>;
|
||||
};
|
||||
interaction_modes: string[];
|
||||
execution_count: number;
|
||||
last_executed?: string;
|
||||
total_cost_cents: number;
|
||||
average_execution_time_ms?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface WorkflowExecution {
|
||||
id: string;
|
||||
workflow_id: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
|
||||
progress_percentage: number;
|
||||
started_at: string;
|
||||
completed_at?: string;
|
||||
tokens_used: number;
|
||||
cost_cents: number;
|
||||
interaction_mode: string;
|
||||
}
|
||||
|
||||
function WorkflowsPageContent() {
|
||||
const [workflows, setWorkflows] = useState<Workflow[]>([]);
|
||||
const [executions, setExecutions] = useState<WorkflowExecution[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [viewMode, setViewMode] = useState<'list' | 'grid' | 'editor' | 'chat' | 'button' | 'form' | 'execution'>('list');
|
||||
const [selectedWorkflow, setSelectedWorkflow] = useState<Workflow | null>(null);
|
||||
const [selectedExecution, setSelectedExecution] = useState<WorkflowExecution | null>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
|
||||
// Load workflows and executions
|
||||
useEffect(() => {
|
||||
const loadData = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const authToken = localStorage.getItem('gt2_token');
|
||||
|
||||
// Load workflows
|
||||
const workflowResponse = await fetch('/api/v1/workflows', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (workflowResponse.ok) {
|
||||
const workflowData = await workflowResponse.json();
|
||||
setWorkflows(workflowData);
|
||||
}
|
||||
|
||||
// Load recent executions for dashboard
|
||||
setExecutions([]); // TODO: Implement executions endpoint
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load workflows:', error);
|
||||
setWorkflows([]);
|
||||
setExecutions([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
loadData();
|
||||
}, []);
|
||||
|
||||
// Filter workflows
|
||||
const filteredWorkflows = workflows.filter(workflow => {
|
||||
const matchesSearch = !searchQuery ||
|
||||
workflow.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
workflow.description?.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesStatus = statusFilter === 'all' || workflow.status === statusFilter;
|
||||
|
||||
return matchesSearch && matchesStatus;
|
||||
});
|
||||
|
||||
// Execute workflow
|
||||
const handleExecuteWorkflow = async (workflow: Workflow, inputData: Record<string, any>) => {
|
||||
try {
|
||||
const authToken = localStorage.getItem('gt2_token');
|
||||
const response = await fetch(`/api/v1/workflows/${workflow.id}/execute`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
input_data: inputData,
|
||||
interaction_mode: viewMode,
|
||||
trigger_type: 'manual'
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const execution = await response.json();
|
||||
setSelectedExecution(execution);
|
||||
return execution;
|
||||
} else {
|
||||
throw new Error('Failed to execute workflow');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to execute workflow:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
// Create new workflow
|
||||
const handleCreateWorkflow = async () => {
|
||||
try {
|
||||
const authToken = localStorage.getItem('gt2_token');
|
||||
const response = await fetch('/api/v1/workflows', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
name: 'New Workflow',
|
||||
description: 'A new workflow created with the visual editor',
|
||||
definition: {
|
||||
nodes: [
|
||||
{
|
||||
id: 'trigger-1',
|
||||
type: 'trigger',
|
||||
data: { name: 'Manual Trigger' },
|
||||
position: { x: 300, y: 200 }
|
||||
}
|
||||
],
|
||||
edges: []
|
||||
},
|
||||
interaction_modes: ['button'],
|
||||
triggers: []
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const newWorkflow = await response.json();
|
||||
setWorkflows(prev => [newWorkflow, ...prev]);
|
||||
setSelectedWorkflow(newWorkflow);
|
||||
setViewMode('editor');
|
||||
} else {
|
||||
alert('Failed to create workflow. Please try again.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to create workflow:', error);
|
||||
alert('Failed to create workflow. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
// Save workflow changes
|
||||
const handleSaveWorkflow = async (definition: any) => {
|
||||
if (!selectedWorkflow) return;
|
||||
|
||||
try {
|
||||
const authToken = localStorage.getItem('gt2_token');
|
||||
const response = await fetch(`/api/v1/workflows/${selectedWorkflow.id}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
definition: definition,
|
||||
status: 'active' // Activate workflow when saved
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const updatedWorkflow = await response.json();
|
||||
setWorkflows(prev => prev.map(w =>
|
||||
w.id === updatedWorkflow.id ? updatedWorkflow : w
|
||||
));
|
||||
setSelectedWorkflow(updatedWorkflow);
|
||||
alert('Workflow saved successfully!');
|
||||
} else {
|
||||
alert('Failed to save workflow.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to save workflow:', error);
|
||||
alert('Failed to save workflow.');
|
||||
}
|
||||
};
|
||||
|
||||
// Delete workflow
|
||||
const handleDeleteWorkflow = async (workflowId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this workflow?')) return;
|
||||
|
||||
try {
|
||||
const authToken = localStorage.getItem('gt2_token');
|
||||
const response = await fetch(`/api/v1/workflows/${workflowId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${authToken}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
setWorkflows(prev => prev.filter(w => w.id !== workflowId));
|
||||
if (selectedWorkflow?.id === workflowId) {
|
||||
setSelectedWorkflow(null);
|
||||
setViewMode('list');
|
||||
}
|
||||
} else {
|
||||
alert('Failed to delete workflow.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to delete workflow:', error);
|
||||
alert('Failed to delete workflow.');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active': return 'bg-green-100 text-green-800';
|
||||
case 'draft': return 'bg-yellow-100 text-yellow-800';
|
||||
case 'paused': return 'bg-orange-100 text-orange-800';
|
||||
case 'archived': return 'bg-gray-100 text-gray-800';
|
||||
default: return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
const getInteractionModeIcon = (mode: string) => {
|
||||
switch (mode) {
|
||||
case 'chat': return MessageSquare;
|
||||
case 'button': return Square;
|
||||
case 'form': return Edit;
|
||||
case 'dashboard': return BarChart3;
|
||||
default: return Square;
|
||||
}
|
||||
};
|
||||
|
||||
// Calculate stats
|
||||
const totalWorkflows = workflows.length;
|
||||
const activeWorkflows = workflows.filter(w => w.status === 'active').length;
|
||||
const totalExecutions = workflows.reduce((sum, w) => sum + w.execution_count, 0);
|
||||
const totalCost = workflows.reduce((sum, w) => sum + w.total_cost_cents, 0);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-64">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-2 border-gt-green border-t-transparent"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Interface views for different interaction modes
|
||||
if ((viewMode === 'chat' || viewMode === 'button' || viewMode === 'form') && selectedWorkflow) {
|
||||
const backToList = () => {
|
||||
setViewMode('list');
|
||||
setSelectedWorkflow(null);
|
||||
};
|
||||
|
||||
const handleExecutionUpdate = (execution: WorkflowExecution) => {
|
||||
setSelectedExecution(execution);
|
||||
// Optionally switch to execution view to show results
|
||||
if (execution.status === 'completed' || execution.status === 'failed') {
|
||||
setViewMode('execution');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Interface Header */}
|
||||
<div className="bg-white border-b border-gray-200 p-4">
|
||||
<div className="max-w-4xl mx-auto flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button variant="ghost" onClick={backToList}>
|
||||
← Back to Workflows
|
||||
</Button>
|
||||
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900">
|
||||
{selectedWorkflow.name}
|
||||
</h1>
|
||||
<p className="text-gray-600 text-sm capitalize">
|
||||
{viewMode} Interface
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={getStatusColor(selectedWorkflow.status)}>
|
||||
{selectedWorkflow.status}
|
||||
</Badge>
|
||||
|
||||
{/* Interface Mode Switcher */}
|
||||
<div className="flex bg-gray-100 rounded-lg p-1">
|
||||
{selectedWorkflow.interaction_modes.map(mode => (
|
||||
<Button
|
||||
key={mode}
|
||||
variant={viewMode === mode ? 'primary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode(mode as any)}
|
||||
className="capitalize"
|
||||
>
|
||||
{mode}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Interface Content */}
|
||||
<div className="max-w-4xl mx-auto p-6">
|
||||
{viewMode === 'chat' && (
|
||||
<WorkflowChatInterface
|
||||
workflow={selectedWorkflow as any}
|
||||
onExecute={(inputData) => handleExecuteWorkflow(selectedWorkflow, inputData)}
|
||||
onExecutionUpdate={handleExecutionUpdate}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'button' && (
|
||||
<WorkflowButtonInterface
|
||||
workflow={selectedWorkflow as any}
|
||||
onExecute={(inputData) => handleExecuteWorkflow(selectedWorkflow, inputData)}
|
||||
onExecutionUpdate={handleExecutionUpdate}
|
||||
showDetailedStats={true}
|
||||
/>
|
||||
)}
|
||||
|
||||
{viewMode === 'form' && (
|
||||
<WorkflowFormInterface
|
||||
workflow={selectedWorkflow as any}
|
||||
onExecute={(inputData) => handleExecuteWorkflow(selectedWorkflow, inputData)}
|
||||
onExecutionUpdate={handleExecutionUpdate}
|
||||
multiStep={selectedWorkflow.definition.nodes.length > 3}
|
||||
showPreview={true}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Execution view
|
||||
if (viewMode === 'execution' && selectedExecution) {
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Execution Header */}
|
||||
<div className="bg-white border-b border-gray-200 p-4">
|
||||
<div className="max-w-6xl mx-auto flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setViewMode('list');
|
||||
setSelectedWorkflow(null);
|
||||
setSelectedExecution(null);
|
||||
}}
|
||||
>
|
||||
← Back to Workflows
|
||||
</Button>
|
||||
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900">
|
||||
Execution Details
|
||||
</h1>
|
||||
<p className="text-gray-600 text-sm">
|
||||
{selectedWorkflow?.name || 'Workflow Execution'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{selectedWorkflow && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setViewMode('button');
|
||||
setSelectedExecution(null);
|
||||
}}
|
||||
>
|
||||
Run Again
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Execution Content */}
|
||||
<div className="max-w-6xl mx-auto p-6">
|
||||
<WorkflowExecutionView
|
||||
execution={selectedExecution as any}
|
||||
workflow={selectedWorkflow as any}
|
||||
onRerun={() => {
|
||||
setViewMode('button');
|
||||
setSelectedExecution(null);
|
||||
}}
|
||||
realtime={selectedExecution.status === 'running'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Editor view
|
||||
if (viewMode === 'editor' && selectedWorkflow) {
|
||||
return (
|
||||
<div className="h-screen flex flex-col">
|
||||
{/* Editor Header */}
|
||||
<div className="bg-white border-b border-gray-200 p-4 flex items-center justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setViewMode('list');
|
||||
setSelectedWorkflow(null);
|
||||
}}
|
||||
>
|
||||
← Back to Workflows
|
||||
</Button>
|
||||
|
||||
<div>
|
||||
<h1 className="text-xl font-bold text-gray-900">
|
||||
{selectedWorkflow.name}
|
||||
</h1>
|
||||
<p className="text-gray-600 text-sm">
|
||||
Visual Workflow Editor
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={getStatusColor(selectedWorkflow.status)}>
|
||||
{selectedWorkflow.status}
|
||||
</Badge>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleExecuteWorkflow(selectedWorkflow, {})}
|
||||
>
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
Test Run
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Enhanced Workflow Canvas */}
|
||||
<div className="flex-1">
|
||||
<EnhancedWorkflowCanvas
|
||||
workflow={selectedWorkflow}
|
||||
onSave={handleSaveWorkflow}
|
||||
onExecute={(definition) => handleExecuteWorkflow(selectedWorkflow, {})}
|
||||
onValidate={(definition) => {
|
||||
// Basic validation logic
|
||||
const errors: Array<{ nodeId?: string; edgeId?: string; message: string; type: 'error' | 'warning' }> = [];
|
||||
|
||||
// Check for orphaned nodes
|
||||
const connectedNodes = new Set();
|
||||
definition.edges.forEach(edge => {
|
||||
connectedNodes.add(edge.source);
|
||||
connectedNodes.add(edge.target);
|
||||
});
|
||||
|
||||
definition.nodes.forEach(node => {
|
||||
if (node.type !== 'trigger' && !connectedNodes.has(node.id)) {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: 'Node is not connected to the workflow',
|
||||
type: 'warning' as const
|
||||
});
|
||||
}
|
||||
|
||||
if (node.type === 'agent' && !node.data.agent_id && !node.data.agent_id) {
|
||||
errors.push({
|
||||
nodeId: node.id,
|
||||
message: 'Agent node requires an agent to be selected',
|
||||
type: 'error' as const
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
isValid: errors.filter(e => e.type === 'error').length === 0,
|
||||
errors
|
||||
};
|
||||
}}
|
||||
autoSave={true}
|
||||
autoSaveInterval={3000}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// List/Grid view
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="bg-white rounded-lg shadow-sm border p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 flex items-center gap-3">
|
||||
<Workflow className="w-8 h-8 text-gt-green" />
|
||||
Workflows
|
||||
</h1>
|
||||
<p className="text-gray-600 mt-1">
|
||||
Create visual workflows using your AI Agents
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* View Mode Toggle */}
|
||||
<div className="flex bg-gray-100 rounded-lg p-1">
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'primary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('list')}
|
||||
>
|
||||
List
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'grid' ? 'primary' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => setViewMode('grid')}
|
||||
>
|
||||
Grid
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button onClick={handleCreateWorkflow} className="bg-gt-green hover:bg-gt-green/90">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Workflow
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="h-4 w-4 text-gray-400 absolute left-3 top-1/2 transform -translate-y-1/2" />
|
||||
<Input
|
||||
placeholder="Search workflows..."
|
||||
value={searchQuery}
|
||||
onChange={(value) => setSearchQuery(value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(e) => setStatusFilter((e as React.ChangeEvent<HTMLSelectElement>).target.value)}
|
||||
className="px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-gt-green focus:border-transparent"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="draft">Draft</option>
|
||||
<option value="paused">Paused</option>
|
||||
<option value="archived">Archived</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Workflows List/Grid */}
|
||||
{filteredWorkflows.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="text-center py-12">
|
||||
<Bot className="h-12 w-12 text-gray-300 mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-900 mb-2">
|
||||
{searchQuery || statusFilter !== 'all' ? 'No workflows found' : 'No workflows yet'}
|
||||
</h3>
|
||||
<p className="text-gray-600 mb-4">
|
||||
{searchQuery || statusFilter !== 'all'
|
||||
? 'Try adjusting your search criteria'
|
||||
: 'Create your first visual workflow to get started'
|
||||
}
|
||||
</p>
|
||||
{!searchQuery && statusFilter === 'all' && (
|
||||
<Button onClick={handleCreateWorkflow}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Your First Workflow
|
||||
</Button>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className={cn(
|
||||
viewMode === 'grid'
|
||||
? "grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"
|
||||
: "space-y-4"
|
||||
)}>
|
||||
{filteredWorkflows.map((workflow) => (
|
||||
<Card
|
||||
key={workflow.id}
|
||||
className="hover:shadow-lg transition-shadow duration-200 cursor-pointer"
|
||||
onClick={() => {
|
||||
setSelectedWorkflow(workflow);
|
||||
setViewMode('editor');
|
||||
}}
|
||||
>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 bg-gradient-to-br from-blue-500 to-purple-600 rounded-lg flex items-center justify-center">
|
||||
<Bot className="h-5 w-5 text-white" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h3 className="font-semibold text-gray-900 truncate">
|
||||
{workflow.name}
|
||||
</h3>
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<span>{workflow.definition.nodes.length} nodes</span>
|
||||
<span>•</span>
|
||||
<span>{workflow.execution_count} runs</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={getStatusColor(workflow.status)}>
|
||||
{workflow.status}
|
||||
</Badge>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
// Show actions menu
|
||||
}}
|
||||
>
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="space-y-3">
|
||||
{workflow.description && (
|
||||
<p className="text-sm text-gray-600 line-clamp-2">
|
||||
{workflow.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Interaction Modes */}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{workflow.interaction_modes.map(mode => {
|
||||
const Icon = getInteractionModeIcon(mode);
|
||||
return (
|
||||
<Badge
|
||||
key={mode}
|
||||
variant="secondary"
|
||||
className="text-xs px-2 py-1 cursor-pointer hover:bg-gray-100"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setSelectedWorkflow(workflow);
|
||||
setViewMode(mode as any);
|
||||
}}
|
||||
>
|
||||
<Icon className="h-3 w-3 mr-1" />
|
||||
{mode}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid grid-cols-3 gap-4 pt-3 border-t border-gray-200">
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500">Executions</p>
|
||||
<p className="font-semibold">{workflow.execution_count}</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500">Avg. Time</p>
|
||||
<p className="font-semibold">
|
||||
{workflow.average_execution_time_ms ?
|
||||
`${Math.round(workflow.average_execution_time_ms / 1000)}s` :
|
||||
'-'
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<p className="text-xs text-gray-500">Cost</p>
|
||||
<p className="font-semibold">
|
||||
${(workflow.total_cost_cents / 100).toFixed(2)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-2 pt-3">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setSelectedWorkflow(workflow);
|
||||
// Use the first available interaction mode for quick run
|
||||
const firstMode = workflow.interaction_modes[0] || 'button';
|
||||
setViewMode(firstMode as any);
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
<Play className="h-3 w-3 mr-1" />
|
||||
Run
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setSelectedWorkflow(workflow);
|
||||
setViewMode('editor');
|
||||
}}
|
||||
className="flex-1"
|
||||
>
|
||||
<Edit className="h-3 w-3 mr-1" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorkflowsPage() {
|
||||
return (
|
||||
<AuthGuard requiredCapabilities={[GT2_CAPABILITIES.AGENTS_EXECUTE]}>
|
||||
<AppLayout>
|
||||
<WorkflowsPageContent />
|
||||
</AppLayout>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user