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:
HackWeasel
2025-12-12 17:04:45 -05:00
commit b9dfb86260
746 changed files with 232071 additions and 0 deletions

View File

@@ -0,0 +1,412 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { apiKeysApi } from '@/lib/api';
import { useToast } from '@/components/ui/use-toast';
import AddApiKeyDialog, { ProviderConfig } from '@/components/api-keys/AddApiKeyDialog';
import {
Key,
Plus,
TestTube,
Trash2,
Loader2,
CheckCircle,
XCircle,
AlertCircle,
AlertTriangle,
} from 'lucide-react';
// Hardcoded tenant for GT AI OS Local (single-tenant deployment)
const TEST_COMPANY_TENANT = {
id: 1,
name: 'HW Workstation Test Deployment',
domain: 'test-company',
};
interface APIKeyStatus {
configured: boolean;
enabled: boolean;
updated_at: string | null;
metadata: Record<string, unknown> | null;
}
// Provider configuration - NVIDIA first (above Groq), then Groq
const PROVIDER_CONFIG: ProviderConfig[] = [
{
id: 'nvidia',
name: 'NVIDIA NIM',
description: 'GPU-accelerated inference on DGX Cloud via build.nvidia.com',
keyPrefix: 'nvapi-',
consoleUrl: 'https://build.nvidia.com/settings/api-keys',
consoleName: 'build.nvidia.com',
},
{
id: 'groq',
name: 'Groq Cloud LLM',
description: 'LPU-accelerated inference via api.groq.com',
keyPrefix: 'gsk_',
consoleUrl: 'https://console.groq.com/keys',
consoleName: 'console.groq.com',
},
];
export default function ApiKeysPage() {
// Auto-select test_company tenant for GT AI OS Local
const selectedTenant = TEST_COMPANY_TENANT;
const selectedTenantId = TEST_COMPANY_TENANT.id;
const [apiKeyStatus, setApiKeyStatus] = useState<Record<string, APIKeyStatus>>({});
const [isLoadingKeys, setIsLoadingKeys] = useState(false);
const [testingProvider, setTestingProvider] = useState<string | null>(null);
const [showAddDialog, setShowAddDialog] = useState(false);
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
const [activeProvider, setActiveProvider] = useState<ProviderConfig | null>(null);
const [testResults, setTestResults] = useState<Record<string, {
success: boolean;
message: string;
error_type?: string;
rate_limit_remaining?: number;
rate_limit_reset?: string;
models_available?: number;
}>>({});
const { toast } = useToast();
// Fetch API keys for test_company tenant
const fetchApiKeys = useCallback(async (tenantId: number) => {
setIsLoadingKeys(true);
setTestResults({});
try {
const response = await apiKeysApi.getTenantKeys(tenantId);
setApiKeyStatus(response.data || {});
} catch (error) {
console.error('Failed to fetch API keys:', error);
setApiKeyStatus({});
} finally {
setIsLoadingKeys(false);
}
}, []);
// Load API keys on mount
useEffect(() => {
fetchApiKeys(selectedTenantId);
}, [selectedTenantId, fetchApiKeys]);
const handleTestConnection = async (provider: ProviderConfig) => {
if (!selectedTenantId) return;
setTestingProvider(provider.id);
setTestResults((prev) => {
const newResults = { ...prev };
delete newResults[provider.id];
return newResults;
});
try {
const response = await apiKeysApi.testKey(selectedTenantId, provider.id);
const result = response.data;
setTestResults((prev) => ({
...prev,
[provider.id]: {
success: result.valid,
message: result.message,
error_type: result.error_type,
rate_limit_remaining: result.rate_limit_remaining,
rate_limit_reset: result.rate_limit_reset,
models_available: result.models_available,
},
}));
// Build toast message with additional info
let description = result.message;
if (result.valid && result.models_available) {
description += ` (${result.models_available} models available)`;
}
toast({
title: result.valid ? 'Connection Successful' : 'Connection Failed',
description: description,
variant: result.valid ? 'default' : 'destructive',
});
} catch (error) {
const message = 'Failed to test connection';
setTestResults((prev) => ({
...prev,
[provider.id]: { success: false, message, error_type: 'connection_error' },
}));
toast({
title: 'Test Failed',
description: message,
variant: 'destructive',
});
} finally {
setTestingProvider(null);
}
};
const handleRemoveKey = async () => {
if (!selectedTenantId || !activeProvider) return;
try {
await apiKeysApi.removeKey(selectedTenantId, activeProvider.id);
toast({
title: 'API Key Removed',
description: `The ${activeProvider.name} API key has been removed`,
});
fetchApiKeys(selectedTenantId);
} catch (error) {
toast({
title: 'Remove Failed',
description: 'Failed to remove API key',
variant: 'destructive',
});
} finally {
setShowRemoveDialog(false);
setActiveProvider(null);
}
};
const handleKeyAdded = () => {
if (selectedTenantId) {
fetchApiKeys(selectedTenantId);
}
};
const openAddDialog = (provider: ProviderConfig) => {
setActiveProvider(provider);
setShowAddDialog(true);
};
const openRemoveDialog = (provider: ProviderConfig) => {
setActiveProvider(provider);
setShowRemoveDialog(true);
};
const formatDate = (dateString: string | null) => {
if (!dateString) return 'Never';
const date = new Date(dateString);
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">API Keys</h1>
<p className="text-muted-foreground">
Manage API keys for external AI providers
</p>
</div>
</div>
{/* API Keys Section - One card per provider */}
{selectedTenant && (
<div className="space-y-6">
{PROVIDER_CONFIG.map((provider) => {
const keyStatus = apiKeyStatus[provider.id];
const testResult = testResults[provider.id];
const isTesting = testingProvider === provider.id;
return (
<Card key={provider.id}>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
<Key className="h-5 w-5" />
{provider.name} API Key
</CardTitle>
<CardDescription>
{provider.description} for {selectedTenant.name}
</CardDescription>
</div>
{!keyStatus?.configured && (
<Button onClick={() => openAddDialog(provider)}>
<Plus className="mr-2 h-4 w-4" />
Add Key
</Button>
)}
</div>
</CardHeader>
<CardContent>
{isLoadingKeys ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : keyStatus?.configured ? (
<div className="space-y-4">
{/* Status Badges */}
<div className="flex items-center gap-2">
<Badge variant="secondary">
<CheckCircle className="mr-1 h-3 w-3" />
Configured
</Badge>
<Badge
variant={keyStatus.enabled ? 'default' : 'destructive'}
>
{keyStatus.enabled ? 'Enabled' : 'Disabled'}
</Badge>
</div>
{/* Key Display */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Key:</span>
<code className="bg-muted px-2 py-1 rounded font-mono">
{provider.keyPrefix}
</code>
</div>
{/* Last Updated */}
<div className="text-sm text-muted-foreground">
Last updated: {formatDate(keyStatus.updated_at)}
</div>
{/* Test Result */}
{testResult && (
<div
className={`flex flex-col gap-2 p-3 rounded-md text-sm ${
testResult.success
? 'bg-green-50 text-green-700 border border-green-200'
: testResult.error_type === 'rate_limited'
? 'bg-yellow-50 text-yellow-700 border border-yellow-200'
: 'bg-red-50 text-red-700 border border-red-200'
}`}
>
<div className="flex items-center gap-2">
{testResult.success ? (
<CheckCircle className="h-4 w-4 flex-shrink-0" />
) : testResult.error_type === 'rate_limited' ? (
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
) : (
<AlertCircle className="h-4 w-4 flex-shrink-0" />
)}
<span>{testResult.message}</span>
</div>
{/* Additional info row */}
{(testResult.models_available || testResult.rate_limit_remaining !== undefined) && (
<div className="flex items-center gap-4 text-xs opacity-80 ml-6">
{testResult.models_available && (
<span>{testResult.models_available} models available</span>
)}
{testResult.rate_limit_remaining !== undefined && (
<span>Rate limit: {testResult.rate_limit_remaining} remaining</span>
)}
</div>
)}
</div>
)}
{/* Actions */}
<div className="flex items-center gap-4 pt-2">
<Button
variant="outline"
onClick={() => handleTestConnection(provider)}
disabled={isTesting || !keyStatus.enabled}
>
{isTesting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Testing...
</>
) : (
<>
<TestTube className="mr-2 h-4 w-4" />
Test Connection
</>
)}
</Button>
<Button
variant="outline"
onClick={() => openAddDialog(provider)}
>
Update Key
</Button>
<Button
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() => openRemoveDialog(provider)}
>
<Trash2 className="mr-2 h-4 w-4" />
Remove
</Button>
</div>
</div>
) : (
<div className="text-center py-8">
<XCircle className="mx-auto h-12 w-12 text-muted-foreground/50" />
<h3 className="mt-4 text-lg font-medium">No API Key Configured</h3>
<p className="mt-2 text-sm text-muted-foreground">
Add a {provider.name} API key to enable AI inference for this tenant.
</p>
<Button
className="mt-4"
onClick={() => openAddDialog(provider)}
>
<Plus className="mr-2 h-4 w-4" />
Configure API Key
</Button>
</div>
)}
</CardContent>
</Card>
);
})}
</div>
)}
{/* Add/Edit Dialog */}
{selectedTenant && activeProvider && (
<AddApiKeyDialog
open={showAddDialog}
onOpenChange={setShowAddDialog}
tenantId={selectedTenant.id}
tenantName={selectedTenant.name}
existingKey={apiKeyStatus[activeProvider.id]?.configured}
onKeyAdded={handleKeyAdded}
provider={activeProvider}
/>
)}
{/* Remove Confirmation Dialog */}
<AlertDialog open={showRemoveDialog} onOpenChange={setShowRemoveDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove API Key?</AlertDialogTitle>
<AlertDialogDescription>
This will permanently remove the {activeProvider?.name} API key for{' '}
<strong>{selectedTenant?.name}</strong>. AI inference using this provider will stop
working for this tenant until a new key is configured.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={handleRemoveKey}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Remove Key
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -0,0 +1,54 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/auth-store';
import { DashboardNav } from '@/components/layout/dashboard-nav';
import { DashboardHeader } from '@/components/layout/dashboard-header';
import { Loader2 } from 'lucide-react';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
const router = useRouter();
const { user, isLoading, isAuthenticated, checkAuth } = useAuthStore();
useEffect(() => {
checkAuth();
}, [checkAuth]);
useEffect(() => {
if (!isLoading && !isAuthenticated) {
router.replace('/auth/login');
}
}, [isAuthenticated, isLoading, router]);
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-screen">
<div className="flex items-center space-x-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-muted-foreground">Loading...</span>
</div>
</div>
);
}
if (!isAuthenticated || !user) {
return null;
}
return (
<div className="min-h-screen bg-background">
<DashboardHeader />
<div className="flex">
<DashboardNav />
<main className="flex-1 p-6">
{children}
</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,32 @@
'use client';
import { DashboardNav } from '@/components/layout/dashboard-nav';
import { DashboardHeader } from '@/components/layout/dashboard-header';
import { AuthGuard } from '@/components/auth/auth-guard';
import { ErrorBoundary } from '@/components/ui/error-boundary';
import { UpdateBanner } from '@/components/system/UpdateBanner';
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<AuthGuard>
<ErrorBoundary>
<div className="min-h-screen bg-background">
<DashboardHeader />
<div className="flex">
<DashboardNav />
<main className="flex-1 p-6">
<UpdateBanner />
<ErrorBoundary>
{children}
</ErrorBoundary>
</main>
</div>
</div>
</ErrorBoundary>
</AuthGuard>
);
}

View File

@@ -0,0 +1,308 @@
"use client";
import { useState, useEffect } from 'react';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Plus, Cpu, Activity } from 'lucide-react';
import { useToast } from '@/components/ui/use-toast';
import ModelRegistryTable from '@/components/models/ModelRegistryTable';
import EndpointConfigurator from '@/components/models/EndpointConfigurator';
import AddModelDialog from '@/components/models/AddModelDialog';
interface ModelStats {
total_models: number;
active_models: number;
inactive_models: number;
providers: Record<string, number>;
}
interface ModelConfig {
model_id: string;
name: string;
provider: string;
model_type: string;
endpoint: string;
description: string | null;
health_status: 'healthy' | 'unhealthy' | 'unknown';
is_active: boolean;
context_window?: number;
max_tokens?: number;
dimensions?: number;
cost_per_million_input?: number;
cost_per_million_output?: number;
capabilities?: Record<string, any>;
last_health_check?: string;
created_at: string;
specifications?: {
context_window: number | null;
max_tokens: number | null;
dimensions: number | null;
};
cost?: {
per_million_input: number;
per_million_output: number;
};
status?: {
is_active: boolean;
health_status: string;
};
}
export default function ModelsPage() {
const [showAddDialog, setShowAddDialog] = useState(false);
const [activeTab, setActiveTab] = useState('registry');
const [stats, setStats] = useState<ModelStats | null>(null);
const [models, setModels] = useState<ModelConfig[]>([]);
const [loading, setLoading] = useState(true);
const [lastFetch, setLastFetch] = useState<number>(0);
const { toast } = useToast();
// Cache data for 30 seconds to prevent excessive requests
const CACHE_DURATION = 30000;
// Fetch all data once at the top level
useEffect(() => {
const fetchAllData = async () => {
// Check cache first
const now = Date.now();
if (models.length > 0 && now - lastFetch < CACHE_DURATION) {
console.log('Using cached data, skipping API call');
return;
}
try {
// Fetch both stats and models in parallel
const [statsResponse, modelsResponse] = await Promise.all([
fetch('/api/v1/models/stats/overview', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
'Content-Type': 'application/json',
},
}),
fetch('/api/v1/models?include_stats=true', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
'Content-Type': 'application/json',
},
})
]);
if (!statsResponse.ok) {
throw new Error(`Stats error! status: ${statsResponse.status}`);
}
if (!modelsResponse.ok) {
throw new Error(`Models error! status: ${modelsResponse.status}`);
}
const [statsData, modelsData] = await Promise.all([
statsResponse.json(),
modelsResponse.json()
]);
setStats(statsData);
// Map API response to component interface
const mappedModels: ModelConfig[] = modelsData.map((model: any) => ({
model_id: model.model_id,
name: model.name,
provider: model.provider,
model_type: model.model_type,
endpoint: model.endpoint,
description: model.description,
health_status: model.status?.health_status || 'unknown',
is_active: model.status?.is_active || false,
context_window: model.specifications?.context_window,
max_tokens: model.specifications?.max_tokens,
dimensions: model.specifications?.dimensions,
cost_per_million_input: model.cost?.per_million_input || 0,
cost_per_million_output: model.cost?.per_million_output || 0,
capabilities: model.capabilities || {},
last_health_check: model.status?.last_health_check,
created_at: model.timestamps?.created_at,
specifications: model.specifications,
cost: model.cost,
status: model.status,
}));
setModels(mappedModels);
setLastFetch(Date.now());
} catch (error) {
console.error('Failed to fetch data:', error);
toast({
title: "Failed to Load Data",
description: "Unable to fetch model data from the server",
variant: "destructive",
});
} finally {
setLoading(false);
}
};
fetchAllData();
}, []); // Remove toast dependency to prevent re-renders
// Refresh data when models are updated
const handleModelUpdated = () => {
setLoading(true);
const fetchAllData = async () => {
try {
console.log('Model updated, forcing fresh data fetch');
const [statsResponse, modelsResponse] = await Promise.all([
fetch('/api/v1/models/stats/overview', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
'Content-Type': 'application/json',
},
}),
fetch('/api/v1/models?include_stats=true', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
'Content-Type': 'application/json',
},
})
]);
if (statsResponse.ok && modelsResponse.ok) {
const [statsData, modelsData] = await Promise.all([
statsResponse.json(),
modelsResponse.json()
]);
setStats(statsData);
const mappedModels: ModelConfig[] = modelsData.map((model: any) => ({
model_id: model.model_id,
name: model.name,
provider: model.provider,
model_type: model.model_type,
endpoint: model.endpoint,
description: model.description,
health_status: model.status?.health_status || 'unknown',
is_active: model.status?.is_active || false,
context_window: model.specifications?.context_window,
max_tokens: model.specifications?.max_tokens,
dimensions: model.specifications?.dimensions,
cost_per_1k_input: model.cost?.per_1k_input || 0,
cost_per_1k_output: model.cost?.per_1k_output || 0,
capabilities: model.capabilities || {},
last_health_check: model.status?.last_health_check,
created_at: model.timestamps?.created_at,
specifications: model.specifications,
cost: model.cost,
status: model.status,
}));
setModels(mappedModels);
setLastFetch(Date.now());
}
} catch (error) {
console.error('Failed to refresh data:', error);
} finally {
setLoading(false);
}
};
fetchAllData();
};
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Models</h1>
<p className="text-muted-foreground">
Configure AI model endpoints and providers
</p>
</div>
<Button onClick={() => setShowAddDialog(true)} className="flex items-center gap-2">
<Plus className="w-4 h-4" />
Add Model
</Button>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Models</CardTitle>
<Cpu className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{loading ? '...' : (stats?.total_models || 0)}
</div>
<p className="text-xs text-muted-foreground">
{loading ? 'Loading...' : `${stats?.active_models || 0} active, ${stats?.inactive_models || 0} inactive`}
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active Models</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">
{loading ? '...' : (stats?.active_models || 0)}
</div>
<p className="text-xs text-muted-foreground">
Available for tenant use
</p>
</CardContent>
</Card>
</div>
{/* Main Content Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="registry">Model Registry</TabsTrigger>
<TabsTrigger value="endpoints">Endpoint Configuration</TabsTrigger>
</TabsList>
<TabsContent value="registry" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Registered Models</CardTitle>
<CardDescription>
Manage AI models available for your tenant. Use the delete option to permanently remove models.
</CardDescription>
</CardHeader>
<CardContent>
<ModelRegistryTable
showArchived={false}
models={models}
loading={loading}
onModelUpdated={handleModelUpdated}
/>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="endpoints" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Endpoint Configurations</CardTitle>
<CardDescription>
API endpoints for model providers
</CardDescription>
</CardHeader>
<CardContent>
<EndpointConfigurator />
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* Add Model Dialog */}
<AddModelDialog
open={showAddDialog}
onOpenChange={setShowAddDialog}
onModelAdded={handleModelUpdated}
/>
</div>
);
}

View File

@@ -0,0 +1,369 @@
'use client';
import { useEffect, useState } from 'react';
import { Activity, AlertTriangle, BarChart3, Clock, Cpu, Database, Globe, Loader2, TrendingUp, Users } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { monitoringApi } from '@/lib/api';
import toast from 'react-hot-toast';
interface SystemMetrics {
cpu_usage: number;
memory_usage: number;
disk_usage: number;
network_io: number;
active_connections: number;
api_calls_per_minute: number;
}
interface TenantMetric {
tenant_id: number;
tenant_name: string;
api_calls: number;
storage_used: number;
active_users: number;
status: string;
}
interface Alert {
id: number;
severity: string;
title: string;
description: string;
timestamp: string;
acknowledged: boolean;
}
export default function MonitoringPage() {
const [systemMetrics, setSystemMetrics] = useState<SystemMetrics | null>(null);
const [tenantMetrics, setTenantMetrics] = useState<TenantMetric[]>([]);
const [alerts, setAlerts] = useState<Alert[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [selectedPeriod, setSelectedPeriod] = useState('24h');
const [refreshInterval, setRefreshInterval] = useState<NodeJS.Timeout | null>(null);
useEffect(() => {
fetchMonitoringData();
// Set up auto-refresh every 30 seconds
const interval = setInterval(() => {
fetchMonitoringData();
}, 30000);
setRefreshInterval(interval);
return () => {
if (interval) clearInterval(interval);
};
}, [selectedPeriod]);
const fetchMonitoringData = async () => {
try {
setIsLoading(true);
// Fetch all monitoring data in parallel
const [systemResponse, tenantResponse, alertsResponse] = await Promise.all([
monitoringApi.systemMetrics().catch(() => null),
monitoringApi.tenantMetrics().catch(() => null),
monitoringApi.alerts(1, 20).catch(() => null)
]);
// Set data from API responses or empty defaults
setSystemMetrics(systemResponse?.data || {
cpu_usage: 0,
memory_usage: 0,
disk_usage: 0,
network_io: 0,
active_connections: 0,
api_calls_per_minute: 0
});
setTenantMetrics(tenantResponse?.data?.tenants || []);
setAlerts(alertsResponse?.data?.alerts || []);
} catch (error) {
console.error('Failed to fetch monitoring data:', error);
toast.error('Failed to load monitoring data');
// Set empty data on error
setSystemMetrics({
cpu_usage: 0,
memory_usage: 0,
disk_usage: 0,
network_io: 0,
active_connections: 0,
api_calls_per_minute: 0
});
setTenantMetrics([]);
setAlerts([]);
} finally {
setIsLoading(false);
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'active':
return <Badge variant="default" className="bg-green-600">Active</Badge>;
case 'warning':
return <Badge variant="default" className="bg-yellow-600">Warning</Badge>;
case 'critical':
return <Badge variant="destructive">Critical</Badge>;
default:
return <Badge variant="secondary">{status}</Badge>;
}
};
const getSeverityBadge = (severity: string) => {
switch (severity) {
case 'critical':
return <Badge variant="destructive">Critical</Badge>;
case 'warning':
return <Badge variant="default" className="bg-yellow-600">Warning</Badge>;
case 'info':
return <Badge variant="secondary">Info</Badge>;
default:
return <Badge variant="secondary">{severity}</Badge>;
}
};
const formatPercentage = (value: number) => {
return `${Math.round(value)}%`;
};
const getUsageColor = (value: number) => {
if (value > 80) return 'text-red-600';
if (value > 60) return 'text-yellow-600';
return 'text-green-600';
};
if (isLoading && !systemMetrics) {
return (
<div className="flex items-center justify-center min-h-[600px]">
<div className="flex items-center space-x-2">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="text-muted-foreground">Loading monitoring data...</span>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">System Monitoring</h1>
<p className="text-muted-foreground">
Real-time system metrics and performance monitoring
</p>
</div>
<div className="flex items-center space-x-2">
<Select value={selectedPeriod} onValueChange={setSelectedPeriod}>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1h">Last Hour</SelectItem>
<SelectItem value="24h">Last 24h</SelectItem>
<SelectItem value="7d">Last 7 Days</SelectItem>
<SelectItem value="30d">Last 30 Days</SelectItem>
</SelectContent>
</Select>
<Button onClick={fetchMonitoringData} variant="secondary">
<Activity className="mr-2 h-4 w-4" />
Refresh
</Button>
</div>
</div>
{/* System Metrics */}
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center">
<Cpu className="h-4 w-4 mr-2" />
CPU Usage
</CardTitle>
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${getUsageColor(systemMetrics?.cpu_usage || 0)}`}>
{formatPercentage(systemMetrics?.cpu_usage || 0)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center">
<Database className="h-4 w-4 mr-2" />
Memory
</CardTitle>
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${getUsageColor(systemMetrics?.memory_usage || 0)}`}>
{formatPercentage(systemMetrics?.memory_usage || 0)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center">
<Database className="h-4 w-4 mr-2" />
Disk Usage
</CardTitle>
</CardHeader>
<CardContent>
<div className={`text-2xl font-bold ${getUsageColor(systemMetrics?.disk_usage || 0)}`}>
{formatPercentage(systemMetrics?.disk_usage || 0)}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center">
<Globe className="h-4 w-4 mr-2" />
Network I/O
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{systemMetrics?.network_io || 0} MB/s
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center">
<Users className="h-4 w-4 mr-2" />
Connections
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{systemMetrics?.active_connections || 0}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center">
<TrendingUp className="h-4 w-4 mr-2" />
API Calls/min
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{systemMetrics?.api_calls_per_minute || 0}
</div>
</CardContent>
</Card>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Tenant Metrics */}
<Card>
<CardHeader>
<CardTitle>Tenant Activity</CardTitle>
<CardDescription>Resource usage by tenant</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Tenant</TableHead>
<TableHead>API Calls</TableHead>
<TableHead>Storage</TableHead>
<TableHead>Users</TableHead>
<TableHead>Status</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tenantMetrics.map((tenant) => (
<TableRow key={tenant.tenant_id}>
<TableCell className="font-medium">{tenant.tenant_name}</TableCell>
<TableCell>{tenant.api_calls.toLocaleString()}</TableCell>
<TableCell>{tenant.storage_used} GB</TableCell>
<TableCell>{tenant.active_users}</TableCell>
<TableCell>{getStatusBadge(tenant.status)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Alerts */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<AlertTriangle className="h-5 w-5 mr-2" />
Recent Alerts
</CardTitle>
<CardDescription>System alerts and notifications</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{alerts.length === 0 ? (
<p className="text-center text-muted-foreground py-4">No active alerts</p>
) : (
alerts.map((alert) => (
<div key={alert.id} className="border rounded-lg p-3 space-y-1">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
{getSeverityBadge(alert.severity)}
<span className="font-medium">{alert.title}</span>
</div>
<span className="text-xs text-muted-foreground">
{new Date(alert.timestamp).toLocaleTimeString()}
</span>
</div>
<p className="text-sm text-muted-foreground">{alert.description}</p>
</div>
))
)}
</div>
</CardContent>
</Card>
</div>
{/* Performance Graph Placeholder */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<BarChart3 className="h-5 w-5 mr-2" />
Performance Trends
</CardTitle>
<CardDescription>System performance over time</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[300px] flex items-center justify-center border-2 border-dashed rounded-lg">
<div className="text-center text-muted-foreground">
<BarChart3 className="h-12 w-12 mx-auto mb-4" />
<p>Performance charts will be displayed here</p>
<p className="text-sm mt-2">Coming soon: Real-time graphs and analytics</p>
</div>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,14 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
export default function DashboardPage() {
const router = useRouter();
useEffect(() => {
router.replace('/dashboard/tenants');
}, [router]);
return null;
}

View File

@@ -0,0 +1,48 @@
'use client';
import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { AlertTriangle, RefreshCw } from 'lucide-react';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error('Agent library page error:', error);
}, [error]);
return (
<div className="flex items-center justify-center min-h-[600px] p-6">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 w-16 h-16 flex items-center justify-center rounded-full bg-red-100">
<AlertTriangle className="w-8 h-8 text-red-600" />
</div>
<CardTitle className="text-red-600">Failed to load agent library</CardTitle>
<CardDescription>
There was a problem loading the agent library page. This could be due to a network issue or server error.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-sm text-muted-foreground bg-muted p-3 rounded-md">
<strong>Error:</strong> {error.message}
</div>
<div className="flex space-x-2">
<Button onClick={reset} className="flex-1">
<RefreshCw className="w-4 h-4 mr-2" />
Try Again
</Button>
<Button variant="secondary" onClick={() => window.location.reload()} className="flex-1">
Reload Page
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { PageLoading } from '@/components/ui/loading';
export default function Loading() {
return <PageLoading text="Loading agent library..." />;
}

View File

@@ -0,0 +1,533 @@
'use client';
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import {
Plus,
Search,
Filter,
Bot,
Brain,
Code,
Shield,
GraduationCap,
Activity,
Eye,
Edit,
Download,
Upload,
Users,
Building2,
Star,
Clock,
CheckCircle,
AlertTriangle,
MoreVertical,
Copy,
Trash2,
Play,
Settings,
GitBranch,
Zap,
Target,
} from 'lucide-react';
import { assistantLibraryApi, tenantsApi } from '@/lib/api';
import toast from 'react-hot-toast';
interface ResourceTemplate {
id: string;
template_id: string;
name: string;
description: string;
category: string; // startup, standard, enterprise
monthly_cost: number;
resources: {
cpu?: { limit: number; unit: string };
memory?: { limit: number; unit: string };
storage?: { limit: number; unit: string };
api_calls?: { limit: number; unit: string };
model_inference?: { limit: number; unit: string };
gpu_time?: { limit: number; unit: string };
};
created_at: string;
updated_at: string;
is_active: boolean;
icon: string;
status: string;
popularity_score: number;
deployment_count: number;
active_instances: number;
version: string;
capabilities: string[];
access_groups: string[];
}
export default function ResourceTemplatesPage() {
const [templates, setTemplates] = useState<ResourceTemplate[]>([]);
const [filteredTemplates, setFilteredTemplates] = useState<ResourceTemplate[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [categoryFilter, setCategoryFilter] = useState('all');
const [selectedTemplates, setSelectedTemplates] = useState<Set<string>>(new Set());
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [selectedTemplate, setSelectedTemplate] = useState<ResourceTemplate | null>(null);
useEffect(() => {
fetchResourceTemplates();
}, []);
const fetchResourceTemplates = async () => {
try {
setLoading(true);
// Use the existing resource management API to get templates (relative URL goes through Next.js rewrites)
const response = await fetch('/api/v1/resource-management/templates');
const data = await response.json();
// Transform the data to match our interface
const templatesData = Object.entries(data.templates || {}).map(([key, template]: [string, any]) => ({
id: key,
template_id: key,
name: template.display_name,
description: template.description,
category: template.name,
monthly_cost: template.monthly_cost,
resources: template.resources,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
is_active: true,
icon: template.icon || '🏢',
status: template.status || 'active',
popularity_score: template.popularity_score || 85,
deployment_count: template.deployment_count || 12,
active_instances: template.active_instances || 45,
version: template.version || '1.0.0',
capabilities: template.capabilities || ['basic_inference', 'text_generation'],
access_groups: template.access_groups || ['standard_users', 'developers']
}));
setTemplates(templatesData);
setFilteredTemplates(templatesData);
} catch (error) {
console.warn('Failed to fetch resource templates:', error);
// Use fallback data from GT 2.0 architecture
const fallbackTemplates = [
{
id: "startup",
template_id: "startup",
name: "Startup",
description: "Basic resources for small teams and development",
category: "startup",
monthly_cost: 99.0,
resources: {
cpu: { limit: 2.0, unit: "cores" },
memory: { limit: 4096, unit: "MB" },
storage: { limit: 10240, unit: "MB" },
api_calls: { limit: 10000, unit: "calls/hour" },
model_inference: { limit: 1000, unit: "tokens" }
},
created_at: "2024-01-10T14:20:00Z",
updated_at: "2024-01-15T10:30:00Z",
is_active: true,
icon: "🚀",
status: "active",
popularity_score: 92,
deployment_count: 15,
active_instances: 32,
version: "1.2.1",
capabilities: ["basic_inference", "text_generation", "code_analysis"],
access_groups: ["startup_users", "basic_developers"]
},
{
id: "standard",
template_id: "standard",
name: "Standard",
description: "Standard resources for production workloads",
category: "standard",
monthly_cost: 299.0,
resources: {
cpu: { limit: 4.0, unit: "cores" },
memory: { limit: 8192, unit: "MB" },
storage: { limit: 51200, unit: "MB" },
api_calls: { limit: 50000, unit: "calls/hour" },
model_inference: { limit: 10000, unit: "tokens" }
},
created_at: "2024-01-05T09:15:00Z",
updated_at: "2024-01-12T16:45:00Z",
is_active: true,
icon: "📈",
status: "active",
popularity_score: 88,
deployment_count: 8,
active_instances: 28,
version: "1.1.0",
capabilities: ["basic_inference", "text_generation", "data_analysis", "visualization"],
access_groups: ["standard_users", "data_analysts", "developers"]
},
{
id: "enterprise",
template_id: "enterprise",
name: "Enterprise",
description: "High-performance resources for large organizations",
category: "enterprise",
monthly_cost: 999.0,
resources: {
cpu: { limit: 16.0, unit: "cores" },
memory: { limit: 32768, unit: "MB" },
storage: { limit: 102400, unit: "MB" },
api_calls: { limit: 200000, unit: "calls/hour" },
model_inference: { limit: 100000, unit: "tokens" },
gpu_time: { limit: 1000, unit: "minutes" }
},
created_at: "2024-01-01T08:30:00Z",
updated_at: "2024-01-18T11:20:00Z",
is_active: true,
icon: "🏢",
status: "active",
popularity_score: 95,
deployment_count: 22,
active_instances: 67,
version: "2.0.0",
capabilities: ["advanced_inference", "multimodal", "code_generation", "function_calling", "custom_training"],
access_groups: ["enterprise_users", "power_users", "admin_users", "ml_engineers"]
}
];
setTemplates(fallbackTemplates);
setFilteredTemplates(fallbackTemplates);
toast.error('Using cached template data - some features may be limited');
} finally {
setLoading(false);
}
};
// Mock data removed - now using real API calls above
// Filter templates based on search and category
useEffect(() => {
let filtered = templates;
// Filter by category
if (categoryFilter !== 'all') {
filtered = filtered.filter(t => t.category === categoryFilter);
}
// Filter by search query
if (searchQuery) {
filtered = filtered.filter(t =>
t.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
t.description.toLowerCase().includes(searchQuery.toLowerCase())
);
}
setFilteredTemplates(filtered);
}, [categoryFilter, searchQuery, templates]);
const getStatusBadge = (status: string) => {
switch (status) {
case 'published':
return <Badge variant="default" className="bg-green-600"><CheckCircle className="h-3 w-3 mr-1" />Published</Badge>;
case 'testing':
return <Badge variant="secondary" className="bg-blue-600"><Activity className="h-3 w-3 mr-1" />Testing</Badge>;
case 'draft':
return <Badge variant="secondary"><Edit className="h-3 w-3 mr-1" />Draft</Badge>;
case 'deprecated':
return <Badge variant="destructive"><AlertTriangle className="h-3 w-3 mr-1" />Deprecated</Badge>;
default:
return <Badge variant="secondary">{status}</Badge>;
}
};
const getCategoryIcon = (category: string) => {
switch (category) {
case 'cybersecurity':
return <Shield className="h-4 w-4" />;
case 'education':
return <GraduationCap className="h-4 w-4" />;
case 'research':
return <Brain className="h-4 w-4" />;
case 'development':
return <Code className="h-4 w-4" />;
case 'general':
return <Bot className="h-4 w-4" />;
default:
return <Bot className="h-4 w-4" />;
}
};
const getCategoryBadge = (category: string) => {
const colors: Record<string, string> = {
cybersecurity: 'bg-red-600',
education: 'bg-green-600',
research: 'bg-purple-600',
development: 'bg-blue-600',
general: 'bg-gray-600',
};
return (
<Badge className={colors[category] || 'bg-gray-600'}>
{getCategoryIcon(category)}
<span className="ml-1">{category.charAt(0).toUpperCase() + category.slice(1)}</span>
</Badge>
);
};
const categoryTabs = [
{ id: 'all', label: 'All Templates', count: templates.length },
{ id: 'startup', label: 'Startup', count: templates.filter(t => t.category === 'startup').length },
{ id: 'standard', label: 'Standard', count: templates.filter(t => t.category === 'standard').length },
{ id: 'enterprise', label: 'Enterprise', count: templates.filter(t => t.category === 'enterprise').length },
];
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Resource Templates</h1>
<p className="text-muted-foreground">
Resource allocation templates for tenant provisioning (startup, standard, enterprise)
</p>
</div>
<div className="flex space-x-2">
<Button>
<Plus className="h-4 w-4 mr-2" />
Create Template
</Button>
</div>
</div>
{/* Analytics Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Available Templates</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{templates.length}</div>
<p className="text-xs text-muted-foreground">
{templates.filter(t => t.is_active).length} active
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Cost Range</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
${templates.length > 0 ? Math.min(...templates.map(t => t.monthly_cost)) : 0} - ${templates.length > 0 ? Math.max(...templates.map(t => t.monthly_cost)) : 0}
</div>
<p className="text-xs text-muted-foreground">
Monthly pricing
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Most Popular</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">Standard</div>
<p className="text-xs text-muted-foreground">
Production workloads
</p>
</CardContent>
</Card>
</div>
{/* Category Tabs */}
<div className="flex space-x-2 border-b">
{categoryTabs.map(tab => (
<button
key={tab.id}
onClick={() => setCategoryFilter(tab.id)}
className={`px-4 py-2 border-b-2 transition-colors ${
categoryFilter === tab.id
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
<span>{tab.label}</span>
<Badge variant="secondary" className="ml-2">{tab.count}</Badge>
</button>
))}
</div>
{/* Search */}
<div className="flex space-x-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search resource templates by name or description..."
value={searchQuery}
onChange={(e) => setSearchQuery((e as React.ChangeEvent<HTMLInputElement>).target.value)}
className="pl-10"
/>
</div>
</div>
{/* Bulk Actions */}
{selectedTemplates.size > 0 && (
<Card className="bg-muted/50">
<CardContent className="flex items-center justify-between py-3">
<span className="text-sm">
{selectedTemplates.size} template{selectedTemplates.size > 1 ? 's' : ''} selected
</span>
<div className="flex space-x-2">
<Button variant="secondary" size="sm">
<Play className="h-4 w-4 mr-2" />
Bulk Deploy
</Button>
<Button variant="secondary" size="sm">
<Copy className="h-4 w-4 mr-2" />
Duplicate
</Button>
<Button variant="secondary" size="sm" className="text-destructive">
<Trash2 className="h-4 w-4 mr-2" />
Archive
</Button>
</div>
</CardContent>
</Card>
)}
{/* Template Gallery */}
{loading ? (
<div className="flex items-center justify-center h-64">
<Activity className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{filteredTemplates.map(template => (
<Card key={template.id} className="hover:shadow-lg transition-shadow cursor-pointer">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-3">
<div className="text-2xl">{template.icon}</div>
<div>
<CardTitle className="text-lg">{template.name}</CardTitle>
<div className="flex items-center space-x-2 mt-1">
{getCategoryBadge(template.category)}
{getStatusBadge(template.status)}
</div>
</div>
</div>
<input
type="checkbox"
checked={selectedTemplates.has(template.id)}
onChange={(e) => {
const newSelected = new Set(selectedTemplates);
if (e.target.checked) {
newSelected.add(template.id);
} else {
newSelected.delete(template.id);
}
setSelectedTemplates(newSelected);
}}
/>
</div>
</CardHeader>
<CardContent className="space-y-4">
<CardDescription className="text-sm">
{template.description}
</CardDescription>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Popularity:</span>
<div className="flex items-center space-x-1">
<Star className="h-3 w-3 text-yellow-500" />
<span className="font-medium">{template.popularity_score}%</span>
</div>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Deployments:</span>
<span className="font-medium">{template.deployment_count}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Active Instances:</span>
<span className="font-medium">{template.active_instances}</span>
</div>
<div className="flex justify-between text-sm">
<span className="text-muted-foreground">Version:</span>
<span className="font-medium">v{template.version}</span>
</div>
</div>
<div className="space-y-2">
<span className="text-sm text-muted-foreground">Capabilities:</span>
<div className="flex flex-wrap gap-1">
{template.capabilities.slice(0, 3).map(capability => (
<Badge key={capability} variant="secondary" className="text-xs">
{capability.replace('_', ' ')}
</Badge>
))}
{template.capabilities.length > 3 && (
<Badge variant="secondary" className="text-xs">
+{template.capabilities.length - 3}
</Badge>
)}
</div>
</div>
<div className="space-y-2">
<span className="text-sm text-muted-foreground">Access Groups:</span>
<div className="flex flex-wrap gap-1">
{template.access_groups.slice(0, 2).map(group => (
<Badge key={group} variant="secondary" className="text-xs">
{group.replace('_', ' ')}
</Badge>
))}
{template.access_groups.length > 2 && (
<Badge variant="secondary" className="text-xs">
+{template.access_groups.length - 2}
</Badge>
)}
</div>
</div>
<div className="text-xs text-muted-foreground">
Updated {new Date(template.updated_at).toLocaleDateString()}
</div>
<div className="flex space-x-2 pt-2">
<Button variant="secondary" size="sm" className="flex-1">
<Eye className="h-4 w-4 mr-1" />
View
</Button>
<Button variant="secondary" size="sm" className="flex-1">
<Play className="h-4 w-4 mr-1" />
Deploy
</Button>
<Button variant="secondary" size="sm">
<MoreVertical className="h-4 w-4" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
{filteredTemplates.length === 0 && !loading && (
<div className="text-center py-12">
<Bot className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<h3 className="text-lg font-medium mb-2">No templates found</h3>
<p className="text-muted-foreground mb-4">
Try adjusting your search criteria or create a new template.
</p>
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="h-4 w-4 mr-2" />
Create Template
</Button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,48 @@
'use client';
import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { AlertTriangle, RefreshCw } from 'lucide-react';
export default function Error({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error('Resources page error:', error);
}, [error]);
return (
<div className="flex items-center justify-center min-h-[600px] p-6">
<Card className="w-full max-w-md">
<CardHeader className="text-center">
<div className="mx-auto mb-4 w-16 h-16 flex items-center justify-center rounded-full bg-red-100">
<AlertTriangle className="w-8 h-8 text-red-600" />
</div>
<CardTitle className="text-red-600">Failed to load resources</CardTitle>
<CardDescription>
There was a problem loading the AI resources page. This could be due to a network issue or server error.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="text-sm text-muted-foreground bg-muted p-3 rounded-md">
<strong>Error:</strong> {error.message}
</div>
<div className="flex space-x-2">
<Button onClick={reset} className="flex-1">
<RefreshCw className="w-4 h-4 mr-2" />
Try Again
</Button>
<Button variant="secondary" onClick={() => window.location.reload()} className="flex-1">
Reload Page
</Button>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,5 @@
import { PageLoading } from '@/components/ui/loading';
export default function Loading() {
return <PageLoading text="Loading AI resources..." />;
}

View File

@@ -0,0 +1,660 @@
'use client';
import { useEffect, useState } from 'react';
import { Plus, Search, Edit, Trash2, Cpu, Loader2, TestTube2, Activity, Globe } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Textarea } from '@/components/ui/textarea';
import { resourcesApi } from '@/lib/api';
import toast from 'react-hot-toast';
interface Resource {
id: number;
uuid: string;
name: string;
description: string;
resource_type: string;
provider: string;
model_name: string;
health_status: string;
is_active: boolean;
primary_endpoint: string;
max_requests_per_minute: number;
cost_per_1k_tokens: number;
created_at: string;
updated_at: string;
}
const RESOURCE_TYPES = [
{ value: 'llm', label: 'Language Model' },
{ value: 'embedding', label: 'Embedding Model' },
{ value: 'vector_database', label: 'Vector Database' },
{ value: 'document_processor', label: 'Document Processor' },
{ value: 'agentic_workflow', label: 'Agent Workflow' },
{ value: 'external_service', label: 'External Service' },
];
const PROVIDERS = [
{ value: 'groq', label: 'Groq' },
{ value: 'openai', label: 'OpenAI' },
{ value: 'anthropic', label: 'Anthropic' },
{ value: 'cohere', label: 'Cohere' },
{ value: 'local', label: 'Local' },
{ value: 'custom', label: 'Custom' },
];
export default function ResourcesPage() {
const [resources, setResources] = useState<Resource[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [selectedType, setSelectedType] = useState<string>('all');
const [selectedProvider, setSelectedProvider] = useState<string>('all');
const [showCreateDialog, setShowCreateDialog] = useState(false);
const [showEditDialog, setShowEditDialog] = useState(false);
const [selectedResource, setSelectedResource] = useState<Resource | null>(null);
const [isCreating, setIsCreating] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const [isTesting, setIsTesting] = useState<number | null>(null);
// Form fields
const [formData, setFormData] = useState({
name: '',
description: '',
resource_type: 'llm',
provider: 'groq',
model_name: '',
primary_endpoint: '',
max_requests_per_minute: 60,
cost_per_1k_tokens: 0
});
useEffect(() => {
fetchResources();
}, []);
const fetchResources = async () => {
try {
setIsLoading(true);
// Add timeout to prevent infinite loading
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
const response = await resourcesApi.list(1, 100);
clearTimeout(timeoutId);
setResources(response.data?.resources || response.data?.data?.resources || []);
} catch (error) {
console.error('Failed to fetch resources:', error);
// No fallback mock data - follow GT 2.0 "No Mocks" principle
setResources([]);
if (error instanceof Error && error.name === 'AbortError') {
toast.error('Request timed out - please try again');
} else {
toast.error('Failed to load resources - please check your connection');
}
} finally {
setIsLoading(false);
}
};
const handleCreate = async () => {
if (!formData.name || !formData.resource_type) {
toast.error('Please fill in all required fields');
return;
}
try {
setIsCreating(true);
await resourcesApi.create({
...formData,
api_endpoints: formData.primary_endpoint ? [formData.primary_endpoint] : [],
failover_endpoints: [],
configuration: {}
});
toast.success('Resource created successfully');
setShowCreateDialog(false);
setFormData({
name: '',
description: '',
resource_type: 'llm',
provider: 'groq',
model_name: '',
primary_endpoint: '',
max_requests_per_minute: 60,
cost_per_1k_tokens: 0
});
fetchResources();
} catch (error: any) {
console.error('Failed to create resource:', error);
toast.error(error.response?.data?.detail || 'Failed to create resource');
} finally {
setIsCreating(false);
}
};
const handleUpdate = async () => {
if (!selectedResource) return;
try {
setIsUpdating(true);
await resourcesApi.update(selectedResource.id, {
name: formData.name,
description: formData.description,
max_requests_per_minute: formData.max_requests_per_minute,
cost_per_1k_tokens: formData.cost_per_1k_tokens
});
toast.success('Resource updated successfully');
setShowEditDialog(false);
fetchResources();
} catch (error: any) {
console.error('Failed to update resource:', error);
toast.error(error.response?.data?.detail || 'Failed to update resource');
} finally {
setIsUpdating(false);
}
};
const handleDelete = async (resource: Resource) => {
if (!confirm(`Are you sure you want to delete ${resource.name}?`)) return;
try {
await resourcesApi.delete(resource.id);
toast.success('Resource deleted successfully');
fetchResources();
} catch (error: any) {
console.error('Failed to delete resource:', error);
toast.error(error.response?.data?.detail || 'Failed to delete resource');
}
};
const handleTestConnection = async (resource: Resource) => {
try {
setIsTesting(resource.id);
await resourcesApi.testConnection(resource.id);
toast.success('Connection test successful');
fetchResources(); // Refresh to get updated health status
} catch (error: any) {
console.error('Failed to test connection:', error);
toast.error(error.response?.data?.detail || 'Connection test failed');
} finally {
setIsTesting(null);
}
};
const openEditDialog = (resource: Resource) => {
setSelectedResource(resource);
setFormData({
name: resource.name,
description: resource.description || '',
resource_type: resource.resource_type,
provider: resource.provider,
model_name: resource.model_name || '',
primary_endpoint: resource.primary_endpoint || '',
max_requests_per_minute: resource.max_requests_per_minute,
cost_per_1k_tokens: resource.cost_per_1k_tokens
});
setShowEditDialog(true);
};
const getHealthBadge = (status: string) => {
switch (status) {
case 'healthy':
return <Badge variant="default" className="bg-green-600">Healthy</Badge>;
case 'unhealthy':
return <Badge variant="destructive">Unhealthy</Badge>;
case 'unknown':
return <Badge variant="secondary">Unknown</Badge>;
default:
return <Badge variant="secondary">{status}</Badge>;
}
};
const getTypeBadge = (type: string) => {
const colors: Record<string, string> = {
llm: 'bg-blue-600',
embedding: 'bg-purple-600',
vector_database: 'bg-orange-600',
document_processor: 'bg-green-600',
agentic_workflow: 'bg-indigo-600',
external_service: 'bg-pink-600'
};
return (
<Badge variant="default" className={colors[type] || 'bg-gray-600'}>
{type.replace('_', ' ').toUpperCase()}
</Badge>
);
};
const filteredResources = resources.filter(resource => {
if (searchQuery && !resource.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
!resource.model_name?.toLowerCase().includes(searchQuery.toLowerCase())) {
return false;
}
if (selectedType !== 'all' && resource.resource_type !== selectedType) {
return false;
}
if (selectedProvider !== 'all' && resource.provider !== selectedProvider) {
return false;
}
return true;
});
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[600px]">
<div className="flex items-center space-x-2">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="text-muted-foreground">Loading resources...</span>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">AI Resources</h1>
<p className="text-muted-foreground">
Manage AI models, RAG engines, and external services
</p>
</div>
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="mr-2 h-4 w-4" />
Add Resource
</Button>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Total Resources</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{resources.length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Active</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{resources.filter(r => r.is_active).length}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Healthy</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">
{resources.filter(r => r.health_status === 'healthy').length}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Unhealthy</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">
{resources.filter(r => r.health_status === 'unhealthy').length}
</div>
</CardContent>
</Card>
</div>
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle>Resource Catalog</CardTitle>
<div className="flex items-center space-x-2">
<div className="relative">
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search resources..."
value={searchQuery}
onChange={(e) => setSearchQuery((e as React.ChangeEvent<HTMLInputElement>).target.value)}
className="pl-8 w-[250px]"
/>
</div>
<Select value={selectedType} onValueChange={setSelectedType}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="All Types" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Types</SelectItem>
{RESOURCE_TYPES.map(type => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select value={selectedProvider} onValueChange={setSelectedProvider}>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder="All Providers" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Providers</SelectItem>
{PROVIDERS.map(provider => (
<SelectItem key={provider.value} value={provider.value}>
{provider.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent>
{filteredResources.length === 0 ? (
<div className="text-center py-12">
<Cpu className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground">No resources found</p>
<Button className="mt-4" onClick={() => setShowCreateDialog(true)}>
<Plus className="mr-2 h-4 w-4" />
Add your first resource
</Button>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Type</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Model</TableHead>
<TableHead>Health</TableHead>
<TableHead>Rate Limit</TableHead>
<TableHead>Cost</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredResources.map((resource) => (
<TableRow key={resource.id}>
<TableCell className="font-medium">{resource.name}</TableCell>
<TableCell>{getTypeBadge(resource.resource_type)}</TableCell>
<TableCell className="capitalize">{resource.provider}</TableCell>
<TableCell>{resource.model_name || '-'}</TableCell>
<TableCell>{getHealthBadge(resource.health_status)}</TableCell>
<TableCell>{resource.max_requests_per_minute}/min</TableCell>
<TableCell>${resource.cost_per_1k_tokens}/1K</TableCell>
<TableCell className="text-right">
<div className="flex justify-end space-x-2">
<Button
size="sm"
variant="secondary"
onClick={() => handleTestConnection(resource)}
disabled={isTesting === resource.id}
title="Test Connection"
>
{isTesting === resource.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<TestTube2 className="h-4 w-4" />
)}
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => openEditDialog(resource)}
title="Edit"
>
<Edit className="h-4 w-4" />
</Button>
<Button
size="sm"
variant="secondary"
onClick={() => handleDelete(resource)}
title="Delete"
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
{/* Create Dialog */}
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Add New Resource</DialogTitle>
<DialogDescription>
Configure a new AI resource for your platform
</DialogDescription>
</DialogHeader>
<div className="grid gap-4 py-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="name">Resource Name *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: (e as React.ChangeEvent<HTMLInputElement>).target.value })}
placeholder="GPT-4 Turbo"
/>
</div>
<div className="space-y-2">
<Label htmlFor="resource_type">Resource Type *</Label>
<Select
value={formData.resource_type}
onValueChange={(value) => setFormData({ ...formData, resource_type: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{RESOURCE_TYPES.map(type => (
<SelectItem key={type.value} value={type.value}>
{type.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="provider">Provider *</Label>
<Select
value={formData.provider}
onValueChange={(value) => setFormData({ ...formData, provider: value })}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PROVIDERS.map(provider => (
<SelectItem key={provider.value} value={provider.value}>
{provider.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="model_name">Model Name</Label>
<Input
id="model_name"
value={formData.model_name}
onChange={(e) => setFormData({ ...formData, model_name: (e as React.ChangeEvent<HTMLInputElement>).target.value })}
placeholder="gpt-4-turbo-preview"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
placeholder="Describe this resource..."
rows={3}
/>
</div>
<div className="space-y-2">
<Label htmlFor="primary_endpoint">API Endpoint</Label>
<Input
id="primary_endpoint"
value={formData.primary_endpoint}
onChange={(e) => setFormData({ ...formData, primary_endpoint: (e as React.ChangeEvent<HTMLInputElement>).target.value })}
placeholder="https://api.example.com/v1"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="max_requests">Rate Limit (req/min)</Label>
<Input
id="max_requests"
type="number"
value={formData.max_requests_per_minute}
onChange={(e) => setFormData({ ...formData, max_requests_per_minute: parseInt((e as React.ChangeEvent<HTMLInputElement>).target.value) || 60 })}
min="1"
/>
</div>
<div className="space-y-2">
<Label htmlFor="cost">Cost per 1K tokens ($)</Label>
<Input
id="cost"
type="number"
step="0.0001"
value={formData.cost_per_1k_tokens}
onChange={(e) => setFormData({ ...formData, cost_per_1k_tokens: parseFloat((e as React.ChangeEvent<HTMLInputElement>).target.value) || 0 })}
min="0"
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="secondary" onClick={() => setShowCreateDialog(false)}>
Cancel
</Button>
<Button onClick={handleCreate} disabled={isCreating}>
{isCreating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating...
</>
) : (
'Create Resource'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Dialog */}
<Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Resource</DialogTitle>
<DialogDescription>
Update resource configuration
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="edit-name">Resource Name</Label>
<Input
id="edit-name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: (e as React.ChangeEvent<HTMLInputElement>).target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-description">Description</Label>
<Textarea
id="edit-description"
value={formData.description}
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
rows={3}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="edit-max_requests">Rate Limit (req/min)</Label>
<Input
id="edit-max_requests"
type="number"
value={formData.max_requests_per_minute}
onChange={(e) => setFormData({ ...formData, max_requests_per_minute: parseInt((e as React.ChangeEvent<HTMLInputElement>).target.value) || 60 })}
min="1"
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-cost">Cost per 1K tokens ($)</Label>
<Input
id="edit-cost"
type="number"
step="0.0001"
value={formData.cost_per_1k_tokens}
onChange={(e) => setFormData({ ...formData, cost_per_1k_tokens: parseFloat((e as React.ChangeEvent<HTMLInputElement>).target.value) || 0 })}
min="0"
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="secondary" onClick={() => setShowEditDialog(false)}>
Cancel
</Button>
<Button onClick={handleUpdate} disabled={isUpdating}>
{isUpdating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Updating...
</>
) : (
'Update Resource'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,397 @@
'use client';
import { useEffect, useState } from 'react';
import { Shield, Lock, AlertTriangle, UserCheck, Activity, FileText, Key, Loader2, Eye, CheckCircle, XCircle } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { securityApi } from '@/lib/api';
import toast from 'react-hot-toast';
interface SecurityEvent {
id: number;
timestamp: string;
event_type: string;
severity: string;
user: string;
ip_address: string;
description: string;
status: string;
}
interface AccessLog {
id: number;
timestamp: string;
user_email: string;
action: string;
resource: string;
result: string;
ip_address: string;
}
interface SecurityPolicy {
id: number;
name: string;
type: string;
status: string;
last_updated: string;
violations: number;
}
export default function SecurityPage() {
const [securityEvents, setSecurityEvents] = useState<SecurityEvent[]>([]);
const [accessLogs, setAccessLogs] = useState<AccessLog[]>([]);
const [policies, setPolicies] = useState<SecurityPolicy[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [selectedSeverity, setSelectedSeverity] = useState('all');
const [selectedTimeRange, setSelectedTimeRange] = useState('24h');
const [searchQuery, setSearchQuery] = useState('');
useEffect(() => {
fetchSecurityData();
}, [selectedSeverity, selectedTimeRange]);
const fetchSecurityData = async () => {
try {
setIsLoading(true);
// Fetch all security data in parallel
const [eventsResponse, logsResponse, policiesResponse] = await Promise.all([
securityApi.getSecurityEvents(1, 20, selectedSeverity === 'all' ? undefined : selectedSeverity, selectedTimeRange).catch(() => null),
securityApi.getAccessLogs(1, 20, selectedTimeRange).catch(() => null),
securityApi.getSecurityPolicies().catch(() => null)
]);
// Set data from API responses or empty defaults
setSecurityEvents(eventsResponse?.data?.events || []);
setAccessLogs(logsResponse?.data?.access_logs || []);
setPolicies(policiesResponse?.data?.policies || []);
} catch (error) {
console.error('Failed to fetch security data:', error);
toast.error('Failed to load security data');
// Set empty arrays on error
setSecurityEvents([]);
setAccessLogs([]);
setPolicies([]);
} finally {
setIsLoading(false);
}
};
const getSeverityBadge = (severity: string) => {
switch (severity) {
case 'critical':
return <Badge variant="destructive">Critical</Badge>;
case 'warning':
return <Badge variant="default" className="bg-yellow-600">Warning</Badge>;
case 'info':
return <Badge variant="secondary">Info</Badge>;
default:
return <Badge variant="secondary">{severity}</Badge>;
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'resolved':
return <Badge variant="default" className="bg-green-600">Resolved</Badge>;
case 'investigating':
return <Badge variant="default" className="bg-blue-600">Investigating</Badge>;
case 'acknowledged':
return <Badge variant="default" className="bg-yellow-600">Acknowledged</Badge>;
default:
return <Badge variant="secondary">{status}</Badge>;
}
};
const getResultBadge = (result: string) => {
return result === 'success' ? (
<Badge variant="default" className="bg-green-600">Success</Badge>
) : (
<Badge variant="destructive">Denied</Badge>
);
};
const getEventIcon = (eventType: string) => {
switch (eventType) {
case 'login_attempt':
return <UserCheck className="h-4 w-4" />;
case 'permission_denied':
return <Lock className="h-4 w-4" />;
case 'brute_force_attempt':
return <AlertTriangle className="h-4 w-4" />;
case 'api_rate_limit':
return <Activity className="h-4 w-4" />;
default:
return <Shield className="h-4 w-4" />;
}
};
const filteredEvents = securityEvents.filter(event => {
if (selectedSeverity !== 'all' && event.severity !== selectedSeverity) {
return false;
}
if (searchQuery && !event.description.toLowerCase().includes(searchQuery.toLowerCase()) &&
!event.user.toLowerCase().includes(searchQuery.toLowerCase())) {
return false;
}
return true;
});
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[600px]">
<div className="flex items-center space-x-2">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="text-muted-foreground">Loading security data...</span>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Security Center</h1>
<p className="text-muted-foreground">
Monitor security events, access logs, and policy compliance
</p>
</div>
<Button variant="secondary">
<FileText className="mr-2 h-4 w-4" />
Export Report
</Button>
</div>
{/* Security Overview Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center">
<Shield className="h-4 w-4 mr-2" />
Security Score
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">92/100</div>
<p className="text-xs text-muted-foreground mt-1">Excellent</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center">
<AlertTriangle className="h-4 w-4 mr-2" />
Active Threats
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">1</div>
<p className="text-xs text-muted-foreground mt-1">Requires attention</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center">
<UserCheck className="h-4 w-4 mr-2" />
Failed Logins
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">3</div>
<p className="text-xs text-muted-foreground mt-1">Last 24 hours</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium flex items-center">
<Lock className="h-4 w-4 mr-2" />
Policy Violations
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-yellow-600">15</div>
<p className="text-xs text-muted-foreground mt-1">This week</p>
</CardContent>
</Card>
</div>
{/* Security Events */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Security Events</CardTitle>
<CardDescription>Real-time security event monitoring</CardDescription>
</div>
<div className="flex items-center space-x-2">
<Input
placeholder="Search events..."
value={searchQuery}
onChange={(e) => setSearchQuery((e as React.ChangeEvent<HTMLInputElement>).target.value)}
className="w-[200px]"
/>
<Select value={selectedSeverity} onValueChange={setSelectedSeverity}>
<SelectTrigger className="w-[130px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Severities</SelectItem>
<SelectItem value="critical">Critical</SelectItem>
<SelectItem value="warning">Warning</SelectItem>
<SelectItem value="info">Info</SelectItem>
</SelectContent>
</Select>
<Select value={selectedTimeRange} onValueChange={setSelectedTimeRange}>
<SelectTrigger className="w-[120px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="1h">Last Hour</SelectItem>
<SelectItem value="24h">Last 24h</SelectItem>
<SelectItem value="7d">Last 7 Days</SelectItem>
<SelectItem value="30d">Last 30 Days</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Time</TableHead>
<TableHead>Event</TableHead>
<TableHead>Severity</TableHead>
<TableHead>User</TableHead>
<TableHead>IP Address</TableHead>
<TableHead>Description</TableHead>
<TableHead>Status</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{filteredEvents.map((event) => (
<TableRow key={event.id}>
<TableCell className="text-sm">
{new Date(event.timestamp).toLocaleString()}
</TableCell>
<TableCell>
<div className="flex items-center space-x-2">
{getEventIcon(event.event_type)}
<span className="text-sm">{event.event_type.replace('_', ' ')}</span>
</div>
</TableCell>
<TableCell>{getSeverityBadge(event.severity)}</TableCell>
<TableCell className="text-sm">{event.user}</TableCell>
<TableCell className="text-sm font-mono">{event.ip_address}</TableCell>
<TableCell className="text-sm">{event.description}</TableCell>
<TableCell>{getStatusBadge(event.status)}</TableCell>
<TableCell>
<Button size="sm" variant="secondary">
<Eye className="h-3 w-3" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Access Logs */}
<Card>
<CardHeader>
<CardTitle>Access Logs</CardTitle>
<CardDescription>Recent access attempts and API calls</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Time</TableHead>
<TableHead>User</TableHead>
<TableHead>Action</TableHead>
<TableHead>Resource</TableHead>
<TableHead>Result</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{accessLogs.map((log) => (
<TableRow key={log.id}>
<TableCell className="text-sm">
{new Date(log.timestamp).toLocaleTimeString()}
</TableCell>
<TableCell className="text-sm">{log.user_email.split('@')[0]}</TableCell>
<TableCell className="text-sm font-medium">{log.action}</TableCell>
<TableCell className="text-sm font-mono text-xs">{log.resource}</TableCell>
<TableCell>{getResultBadge(log.result)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Security Policies */}
<Card>
<CardHeader>
<CardTitle>Security Policies</CardTitle>
<CardDescription>Active security policies and compliance</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{policies.map((policy) => (
<div key={policy.id} className="border rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center space-x-2">
<Key className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{policy.name}</span>
</div>
{policy.status === 'active' ? (
<CheckCircle className="h-4 w-4 text-green-600" />
) : (
<XCircle className="h-4 w-4 text-red-600" />
)}
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Type: {policy.type.replace('_', ' ')}</span>
{policy.violations > 0 && (
<Badge variant="destructive" className="text-xs">
{policy.violations} violations
</Badge>
)}
</div>
<div className="text-xs text-muted-foreground mt-1">
Updated {new Date(policy.last_updated).toLocaleDateString()}
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
'use client';
import { Shield } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { TFASettings } from '@/components/settings/tfa-settings';
export default function SettingsPage() {
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Settings</h1>
<p className="text-muted-foreground">
Manage your account settings and preferences
</p>
</div>
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Shield className="h-5 w-5 mr-2" />
Security Settings
</CardTitle>
<CardDescription>
Manage your account security and access controls
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Two-Factor Authentication Component */}
<TFASettings />
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,400 @@
'use client';
import { useEffect, useState } from 'react';
import { Server, Database, HardDrive, Activity, CheckCircle, XCircle, AlertTriangle, Loader2, RefreshCw, Settings2, Cloud, Layers } from 'lucide-react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Progress } from '@/components/ui/progress';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { systemApi } from '@/lib/api';
import toast from 'react-hot-toast';
import { BackupManager } from '@/components/system/BackupManager';
import { UpdateModal } from '@/components/system/UpdateModal';
interface SystemHealth {
overall_status: string;
uptime: string;
version: string;
environment: string;
}
interface ClusterInfo {
name: string;
status: string;
nodes: number;
pods: number;
cpu_usage: number;
memory_usage: number;
storage_usage: number;
}
interface ServiceStatus {
name: string;
status: string;
health: string;
version: string;
uptime: string;
last_check: string;
}
interface SystemConfig {
key: string;
value: string;
category: string;
editable: boolean;
}
interface SystemHealthDetailed {
overall_status: string;
containers: Array<{
name: string;
cluster: string;
state: string;
health: string;
uptime: string;
ports: string[];
}>;
clusters: Array<{
name: string;
healthy: number;
unhealthy: number;
total: number;
}>;
database: {
connections_active: number;
connections_max: number;
cache_hit_ratio: number;
database_size: string;
transactions_committed: number;
};
version: string;
}
interface UpdateInfo {
current_version: string;
latest_version: string;
update_type: 'major' | 'minor' | 'patch';
release_notes: string;
released_at: string;
}
export default function SystemPage() {
const [systemHealth, setSystemHealth] = useState<SystemHealth | null>(null);
const [healthData, setHealthData] = useState<SystemHealthDetailed | null>(null);
const [clusters, setClusters] = useState<ClusterInfo[]>([]);
const [services, setServices] = useState<ServiceStatus[]>([]);
const [configs, setConfigs] = useState<SystemConfig[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isRefreshing, setIsRefreshing] = useState(false);
const [currentVersion, setCurrentVersion] = useState<string>('');
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false);
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
const [showUpdateModal, setShowUpdateModal] = useState(false);
useEffect(() => {
fetchSystemData();
}, []);
const fetchSystemData = async (showRefreshIndicator = false) => {
try {
if (showRefreshIndicator) {
setIsRefreshing(true);
} else {
setIsLoading(true);
}
// Fetch system data from API
const [healthResponse, healthDetailedResponse] = await Promise.all([
systemApi.health().catch(() => null),
systemApi.healthDetailed().catch(() => null)
]);
// Set system health from API response or defaults
setSystemHealth({
overall_status: healthDetailedResponse?.data?.overall_status || healthResponse?.data?.status || 'unknown',
uptime: '0 days',
version: healthDetailedResponse?.data?.version || '2.0.0',
environment: 'development'
});
// Set detailed health data for Database & Storage section
if (healthDetailedResponse?.data) {
setHealthData(healthDetailedResponse.data);
}
// Clear clusters, services, configs - not used in current UI
setClusters([]);
setServices([]);
setConfigs([]);
// Fetch version info
try {
const versionResponse = await systemApi.version();
// Use either 'current_version' or 'version' field for compatibility
setCurrentVersion(versionResponse.data.current_version || versionResponse.data.version || '2.0.0');
} catch (error) {
console.error('Failed to fetch version:', error);
}
if (showRefreshIndicator) {
toast.success('System data refreshed');
}
} catch (error) {
console.error('Failed to fetch system data:', error);
toast.error('Failed to load system data');
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'healthy':
case 'running':
return <CheckCircle className="h-5 w-5 text-green-600" />;
case 'warning':
return <AlertTriangle className="h-5 w-5 text-yellow-600" />;
case 'unhealthy':
case 'stopped':
return <XCircle className="h-5 w-5 text-red-600" />;
default:
return <Activity className="h-5 w-5 text-gray-600" />;
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'healthy':
case 'running':
return <Badge variant="default" className="bg-green-600">Healthy</Badge>;
case 'warning':
return <Badge variant="default" className="bg-yellow-600">Warning</Badge>;
case 'unhealthy':
case 'stopped':
return <Badge variant="destructive">Unhealthy</Badge>;
default:
return <Badge variant="secondary">{status}</Badge>;
}
};
const getUsageColor = (value: number) => {
if (value > 80) return 'bg-red-600';
if (value > 60) return 'bg-yellow-600';
return 'bg-green-600';
};
const handleCheckForUpdates = async () => {
setIsCheckingUpdate(true);
try {
const response = await systemApi.checkUpdate();
if (response.data.update_available) {
// Map backend response to UpdateInfo format
const info: UpdateInfo = {
current_version: response.data.current_version,
latest_version: response.data.latest_version,
update_type: response.data.update_type || 'patch',
release_notes: response.data.release_notes || '',
released_at: response.data.released_at || response.data.published_at || ''
};
setUpdateInfo(info);
setShowUpdateModal(true);
toast.success(`Update available: v${response.data.latest_version}`);
} else {
toast.success('System is up to date');
}
} catch (error) {
console.error('Failed to check for updates:', error);
toast.error('Failed to check for updates');
} finally {
setIsCheckingUpdate(false);
}
};
const handleUpdateModalClose = () => {
setShowUpdateModal(false);
// Refresh system data after modal closes (in case update was performed)
fetchSystemData();
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[600px]">
<div className="flex items-center space-x-2">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="text-muted-foreground">Loading system information...</span>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">System Management</h1>
<p className="text-muted-foreground">
System health, cluster status, and configuration
</p>
</div>
<Button
onClick={() => fetchSystemData(true)}
disabled={isRefreshing}
>
{isRefreshing ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Refreshing...
</>
) : (
<>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</>
)}
</Button>
</div>
{/* System Overview */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">System Status</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center space-x-2">
{getStatusIcon(systemHealth?.overall_status || 'unknown')}
<span className="text-2xl font-bold capitalize">
{systemHealth?.overall_status || 'Unknown'}
</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Uptime</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{systemHealth?.uptime || '-'}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Version</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{systemHealth?.version || '-'}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-medium">Environment</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold capitalize">{systemHealth?.environment || '-'}</div>
</CardContent>
</Card>
</div>
{/* Software Updates */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Cloud className="h-5 w-5 mr-2" />
Software Updates
</CardTitle>
<CardDescription>Manage system software updates</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<div className="text-sm text-muted-foreground">Current Version</div>
<div className="text-2xl font-bold">{currentVersion || systemHealth?.version || '-'}</div>
</div>
<div className="flex items-end">
<Button
onClick={handleCheckForUpdates}
disabled={isCheckingUpdate}
className="w-full"
>
{isCheckingUpdate ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Checking...
</>
) : (
'Check for Updates'
)}
</Button>
</div>
</div>
</CardContent>
</Card>
{/* Backup Management */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<HardDrive className="h-5 w-5 mr-2" />
Backup Management
</CardTitle>
<CardDescription>Create and restore system backups</CardDescription>
</CardHeader>
<CardContent>
<BackupManager />
</CardContent>
</Card>
{/* Database Status */}
<Card>
<CardHeader>
<CardTitle className="flex items-center">
<Database className="h-5 w-5 mr-2" />
Database & Storage
</CardTitle>
<CardDescription>Database connections and storage metrics</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<div className="text-sm text-muted-foreground">PostgreSQL Connections</div>
<div className="text-2xl font-bold">
{healthData?.database?.connections_active ?? '-'} / {healthData?.database?.connections_max ?? '-'}
</div>
<Progress value={healthData?.database ? (healthData.database.connections_active / healthData.database.connections_max) * 100 : 0} className="h-2" />
</div>
<div className="space-y-2">
<div className="text-sm text-muted-foreground">Cache Hit Ratio</div>
<div className="text-2xl font-bold">{healthData?.database?.cache_hit_ratio ?? '-'}%</div>
<Progress value={healthData?.database?.cache_hit_ratio ?? 0} className="h-2" />
</div>
<div className="space-y-2">
<div className="text-sm text-muted-foreground">Database Size</div>
<div className="text-2xl font-bold">{healthData?.database?.database_size ?? '-'}</div>
</div>
</div>
</CardContent>
</Card>
{/* Update Modal */}
{updateInfo && (
<UpdateModal
updateInfo={updateInfo}
open={showUpdateModal}
onClose={handleUpdateModalClose}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,209 @@
"use client";
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
FileText,
Download,
Upload,
Trash2,
Plus,
Loader2,
CheckCircle
} from 'lucide-react';
import { useToast } from '@/components/ui/use-toast';
import { TemplatePreview } from '@/components/templates/TemplatePreview';
import { ApplyTemplateModal } from '@/components/templates/ApplyTemplateModal';
import { ExportTemplateModal } from '@/components/templates/ExportTemplateModal';
interface Template {
id: number;
name: string;
description: string;
is_default: boolean;
resource_counts: {
models: number;
agents: number;
datasets: number;
};
created_at: string;
}
export default function TemplatesPage() {
const { toast } = useToast();
const [templates, setTemplates] = useState<Template[]>([]);
const [loading, setLoading] = useState(true);
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
const [showApplyModal, setShowApplyModal] = useState(false);
const [showExportModal, setShowExportModal] = useState(false);
useEffect(() => {
fetchTemplates();
}, []);
const fetchTemplates = async () => {
try {
setLoading(true);
const response = await fetch('/api/v1/templates/');
if (!response.ok) throw new Error('Failed to fetch templates');
const data = await response.json();
setTemplates(data);
} catch (error) {
toast({
title: "Error",
description: "Failed to load templates",
variant: "destructive"
});
} finally {
setLoading(false);
}
};
const handleApplyTemplate = (template: Template) => {
setSelectedTemplate(template);
setShowApplyModal(true);
};
const handleDeleteTemplate = async (templateId: number, templateName: string) => {
if (!confirm(`Are you sure you want to delete template "${templateName}"?`)) {
return;
}
try {
const response = await fetch(`/api/v1/templates/${templateId}`, {
method: 'DELETE'
});
if (!response.ok) throw new Error('Failed to delete template');
toast({
title: "Success",
description: `Template "${templateName}" deleted successfully`
});
fetchTemplates();
} catch (error) {
toast({
title: "Error",
description: "Failed to delete template",
variant: "destructive"
});
}
};
const onApplySuccess = () => {
setShowApplyModal(false);
setSelectedTemplate(null);
toast({
title: "Success",
description: "Template applied successfully"
});
};
const onExportSuccess = () => {
setShowExportModal(false);
fetchTemplates();
toast({
title: "Success",
description: "Template exported successfully"
});
};
return (
<div className="p-6 space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Tenant Templates</h1>
<p className="text-muted-foreground mt-1">
Manage and apply configuration templates to tenants
</p>
</div>
<Button onClick={() => setShowExportModal(true)}>
<Plus className="h-4 w-4 mr-2" />
Export Current Tenant
</Button>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-8 w-8 animate-spin" />
</div>
) : templates.length === 0 ? (
<Card>
<CardContent className="py-12">
<div className="text-center text-muted-foreground">
<FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-lg">No templates found</p>
<p className="text-sm mt-1">Export your current tenant to create a template</p>
</div>
</CardContent>
</Card>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{templates.map((template) => (
<Card key={template.id} className="hover:shadow-lg transition-shadow">
<CardHeader>
<div className="flex justify-between items-start">
<div>
<CardTitle className="flex items-center gap-2">
{template.name}
{template.is_default && (
<Badge variant="default">Default</Badge>
)}
</CardTitle>
<CardDescription className="mt-1">
{template.description || 'No description'}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
<TemplatePreview template={template} />
<div className="flex gap-2">
<Button
onClick={() => handleApplyTemplate(template)}
className="flex-1"
>
<Upload className="h-4 w-4 mr-2" />
Apply
</Button>
{!template.is_default && (
<Button
variant="outline"
size="icon"
onClick={() => handleDeleteTemplate(template.id, template.name)}
>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
{selectedTemplate && (
<ApplyTemplateModal
open={showApplyModal}
onClose={() => {
setShowApplyModal(false);
setSelectedTemplate(null);
}}
template={selectedTemplate}
onSuccess={onApplySuccess}
/>
)}
<ExportTemplateModal
open={showExportModal}
onClose={() => setShowExportModal(false)}
onSuccess={onExportSuccess}
/>
</div>
);
}

View File

@@ -0,0 +1,283 @@
'use client';
import { useEffect, useState } from 'react';
import { Edit, Building2, Loader2, Power } from 'lucide-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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import { tenantsApi } from '@/lib/api';
import toast from 'react-hot-toast';
interface Tenant {
id: number;
uuid: string;
name: string;
domain: string;
template: string;
status: string;
max_users: number;
user_count: number;
resource_limits: any;
namespace: string;
frontend_url?: string;
optics_enabled?: boolean;
created_at: string;
updated_at: string;
// Budget configuration
monthly_budget_cents?: number | null;
budget_warning_threshold?: number | null;
budget_critical_threshold?: number | null;
budget_enforcement_enabled?: boolean | null;
// Storage pricing - Hot tier only
storage_price_dataset_hot?: number | null;
storage_price_conversation_hot?: number | null;
// Cold tier allocation-based
cold_storage_allocated_tibs?: number | null;
cold_storage_price_per_tib?: number | null;
}
export default function TenantsPage() {
const [tenants, setTenants] = useState<Tenant[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [showEditDialog, setShowEditDialog] = useState(false);
const [selectedTenant, setSelectedTenant] = useState<Tenant | null>(null);
const [isUpdating, setIsUpdating] = useState(false);
// Form fields (simplified for Community Edition)
const [formData, setFormData] = useState({
name: '',
frontend_url: '',
});
useEffect(() => {
fetchTenants();
}, []);
const fetchTenants = async () => {
try {
setIsLoading(true);
const response = await tenantsApi.list(1, 100);
setTenants(response.data.tenants || []);
} catch (error) {
console.error('Failed to fetch tenants:', error);
toast.error('Failed to load tenant');
} finally {
setIsLoading(false);
}
};
const handleUpdate = async () => {
if (!selectedTenant) return;
try {
setIsUpdating(true);
await tenantsApi.update(selectedTenant.id, {
name: formData.name,
frontend_url: formData.frontend_url,
});
toast.success('Tenant updated successfully');
setShowEditDialog(false);
fetchTenants();
} catch (error: any) {
console.error('Failed to update tenant:', error);
toast.error(error.response?.data?.detail || 'Failed to update tenant');
} finally {
setIsUpdating(false);
}
};
const handleDeploy = async (tenant: Tenant) => {
try {
await tenantsApi.deploy(tenant.id);
toast.success('Deployment initiated for ' + tenant.name);
fetchTenants();
} catch (error: any) {
console.error('Failed to deploy tenant:', error);
toast.error(error.response?.data?.detail || 'Failed to deploy tenant');
}
};
const openEditDialog = (tenant: Tenant) => {
setSelectedTenant(tenant);
setFormData({
...formData,
name: tenant.name,
frontend_url: tenant.frontend_url || '',
});
setShowEditDialog(true);
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'active':
return <Badge variant="default" className="bg-green-600">Active</Badge>;
case 'pending':
return <Badge variant="secondary">Pending</Badge>;
case 'suspended':
return <Badge variant="destructive">Suspended</Badge>;
case 'deploying':
return <Badge variant="secondary">Deploying</Badge>;
case 'archived':
return <Badge variant="secondary">Archived</Badge>;
default:
return <Badge variant="secondary">{status}</Badge>;
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[600px]">
<div className="flex items-center space-x-2">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="text-muted-foreground">Loading tenant...</span>
</div>
</div>
);
}
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Tenant</h1>
<p className="text-muted-foreground">
Manage your tenant configuration
</p>
<p className="text-sm text-amber-600 mt-1">
GT AI OS Community Edition: Limited to 50 users per tenant
</p>
</div>
</div>
{tenants.length === 0 ? (
<Card>
<CardContent className="py-12">
<div className="text-center">
<Building2 className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
<p className="text-muted-foreground">No tenant configured</p>
</div>
</CardContent>
</Card>
) : (
tenants.map((tenant) => (
<Card key={tenant.id}>
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-3">
<Building2 className="h-6 w-6 text-muted-foreground" />
<div>
<CardTitle>{tenant.name}</CardTitle>
<p className="text-sm text-muted-foreground">
{tenant.frontend_url || 'http://localhost:3002'}
</p>
</div>
</div>
<div className="flex items-center space-x-2">
{getStatusBadge(tenant.status)}
{tenant.status === 'pending' && (
<Button
size="sm"
variant="secondary"
onClick={() => handleDeploy(tenant)}
>
<Power className="h-4 w-4 mr-2" />
Deploy
</Button>
)}
<Button
size="sm"
variant="secondary"
onClick={() => openEditDialog(tenant)}
>
<Edit className="h-4 w-4 mr-2" />
Edit
</Button>
</div>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<p className="text-sm font-medium text-muted-foreground">Users</p>
<p className="text-2xl font-bold">{tenant.user_count} <span className="text-sm font-normal text-muted-foreground">/ 50</span></p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Domain</p>
<p className="text-lg font-medium">{tenant.domain}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Created</p>
<p className="text-lg font-medium">{new Date(tenant.created_at).toLocaleDateString()}</p>
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">Status</p>
<p className="text-lg font-medium capitalize">{tenant.status}</p>
</div>
</div>
</CardContent>
</Card>
))
)}
{/* Edit Dialog */}
<Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit Tenant</DialogTitle>
<DialogDescription>
Update tenant configuration
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="edit-name">Tenant Name</Label>
<Input
id="edit-name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: (e as React.ChangeEvent<HTMLInputElement>).target.value })}
/>
</div>
<div className="space-y-2">
<Label htmlFor="edit-frontend_url">Frontend URL (Optional)</Label>
<Input
id="edit-frontend_url"
value={formData.frontend_url}
onChange={(e) => setFormData({ ...formData, frontend_url: (e as React.ChangeEvent<HTMLInputElement>).target.value })}
placeholder="https://app.company.com or http://localhost:3002"
/>
<p className="text-xs text-muted-foreground">
Custom frontend URL for this tenant. Leave blank to use http://localhost:3002
</p>
</div>
</div>
<DialogFooter>
<Button variant="secondary" onClick={() => setShowEditDialog(false)}>
Cancel
</Button>
<Button onClick={handleUpdate} disabled={isUpdating}>
{isUpdating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Updating...
</>
) : (
'Update Tenant'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,646 @@
'use client';
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { usersApi } from '@/lib/api';
import toast from 'react-hot-toast';
import AddUserDialog from '@/components/users/AddUserDialog';
import EditUserDialog from '@/components/users/EditUserDialog';
import DeleteUserDialog from '@/components/users/DeleteUserDialog';
import BulkUploadDialog from '@/components/users/BulkUploadDialog';
import {
Plus,
Search,
Filter,
Users,
User,
Shield,
Key,
Building2,
Activity,
CheckCircle,
XCircle,
AlertTriangle,
Settings,
Eye,
Mail,
Calendar,
Clock,
MoreVertical,
UserCog,
ShieldCheck,
Lock,
Edit,
Trash2,
Upload,
RotateCcw,
ShieldOff,
} from 'lucide-react';
interface UserType {
id: number;
email: string;
full_name: string;
user_type: 'super_admin' | 'tenant_admin' | 'tenant_user';
tenant_id?: number;
tenant_name?: string;
status: 'active' | 'inactive' | 'suspended';
capabilities: string[];
access_groups: string[];
last_login?: string;
created_at: string;
tfa_enabled?: boolean;
tfa_required?: boolean;
tfa_status?: 'disabled' | 'enabled' | 'enforced';
}
export default function UsersPage() {
const [users, setUsers] = useState<UserType[]>([]);
const [filteredUsers, setFilteredUsers] = useState<UserType[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [typeFilter, setTypeFilter] = useState('all');
const [selectedUsers, setSelectedUsers] = useState<Set<number>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const [totalUsers, setTotalUsers] = useState(0);
const [limit] = useState(20);
const [searchInput, setSearchInput] = useState('');
const [roleCounts, setRoleCounts] = useState({
super_admin: 0,
tenant_admin: 0,
tenant_user: 0,
});
// Dialog states
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [bulkUploadDialogOpen, setBulkUploadDialogOpen] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
const [userToDelete, setUserToDelete] = useState<{
id: number;
email: string;
full_name: string;
user_type: string;
} | null>(null);
// Fetch role counts on mount
useEffect(() => {
fetchRoleCounts();
}, []);
// Fetch real users from API - GT 2.0 "No Mocks" principle
useEffect(() => {
fetchUsers();
}, [currentPage, searchQuery, typeFilter]);
const fetchRoleCounts = async () => {
try {
// Fetch counts for each role
const [superAdminRes, tenantAdminRes, tenantUserRes] = await Promise.all([
usersApi.list(1, 1, undefined, undefined, 'super_admin'),
usersApi.list(1, 1, undefined, undefined, 'tenant_admin'),
usersApi.list(1, 1, undefined, undefined, 'tenant_user'),
]);
setRoleCounts({
super_admin: superAdminRes.data?.total || 0,
tenant_admin: tenantAdminRes.data?.total || 0,
tenant_user: tenantUserRes.data?.total || 0,
});
} catch (error) {
console.error('Failed to fetch role counts:', error);
}
};
const fetchUsers = async () => {
try {
setLoading(true);
const response = await usersApi.list(
currentPage,
limit,
searchQuery || undefined,
undefined,
typeFilter !== 'all' ? typeFilter : undefined
);
const userData = response.data?.users || response.data?.data || [];
setTotalUsers(response.data?.total || 0);
// Map API response to expected format
const mappedUsers: UserType[] = userData.map((user: any) => ({
...user,
status: user.is_active ? 'active' : 'suspended',
capabilities: user.capabilities || [],
access_groups: user.access_groups || [],
}));
setUsers(mappedUsers);
setFilteredUsers(mappedUsers);
} catch (error) {
console.error('Failed to fetch users:', error);
toast.error('Failed to load users');
setUsers([]);
setFilteredUsers([]);
} finally {
setLoading(false);
}
};
const handleSearch = () => {
setSearchQuery(searchInput);
setCurrentPage(1); // Reset to first page on new search
};
const handleSearchKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
handleSearch();
}
};
const handleTypeFilterChange = (newFilter: string) => {
setTypeFilter(newFilter);
setCurrentPage(1); // Reset to first page on filter change
};
const handleSelectAll = async () => {
// Fetch all user IDs with current filters
try {
const response = await usersApi.list(
1,
totalUsers, // Get all users
searchQuery || undefined,
undefined,
typeFilter !== 'all' ? typeFilter : undefined
);
const allUserIds = response.data?.users?.map((u: any) => u.id) || [];
setSelectedUsers(new Set(allUserIds));
} catch (error) {
console.error('Failed to fetch all users:', error);
toast.error('Failed to select all users');
}
};
const handleDeleteSelected = async () => {
if (selectedUsers.size === 0) return;
const confirmMessage = `Are you sure you want to permanently delete ${selectedUsers.size} user${selectedUsers.size > 1 ? 's' : ''}? This action cannot be undone.`;
if (!confirm(confirmMessage)) return;
try {
// Delete each selected user
const deletePromises = Array.from(selectedUsers).map(userId =>
usersApi.delete(userId)
);
await Promise.all(deletePromises);
toast.success(`Successfully deleted ${selectedUsers.size} user${selectedUsers.size > 1 ? 's' : ''}`);
setSelectedUsers(new Set());
fetchUsers(); // Reload the user list
fetchRoleCounts(); // Update role counts
} catch (error) {
console.error('Failed to delete users:', error);
toast.error('Failed to delete some users');
fetchUsers(); // Reload to show which users were actually deleted
}
};
const handleResetTFA = async () => {
if (selectedUsers.size === 0) return;
const confirmMessage = `Reset 2FA for ${selectedUsers.size} user${selectedUsers.size > 1 ? 's' : ''}? They will need to set up 2FA again if required.`;
if (!confirm(confirmMessage)) return;
try {
const userIds = Array.from(selectedUsers);
const response = await usersApi.bulkResetTFA(userIds);
const result = response.data;
if (result.failed_count > 0) {
toast.error(`Reset 2FA for ${result.success_count} users, ${result.failed_count} failed`);
} else {
toast.success(`Successfully reset 2FA for ${result.success_count} user${result.success_count > 1 ? 's' : ''}`);
}
setSelectedUsers(new Set());
fetchUsers();
} catch (error) {
console.error('Failed to reset 2FA:', error);
toast.error('Failed to reset 2FA');
}
};
const handleEnforceTFA = async () => {
if (selectedUsers.size === 0) return;
const confirmMessage = `Enforce 2FA for ${selectedUsers.size} user${selectedUsers.size > 1 ? 's' : ''}? They will be required to set up 2FA on next login.`;
if (!confirm(confirmMessage)) return;
try {
const userIds = Array.from(selectedUsers);
const response = await usersApi.bulkEnforceTFA(userIds);
const result = response.data;
if (result.failed_count > 0) {
toast.error(`Enforced 2FA for ${result.success_count} users, ${result.failed_count} failed`);
} else {
toast.success(`Successfully enforced 2FA for ${result.success_count} user${result.success_count > 1 ? 's' : ''}`);
}
setSelectedUsers(new Set());
fetchUsers();
} catch (error) {
console.error('Failed to enforce 2FA:', error);
toast.error('Failed to enforce 2FA');
}
};
const handleDisableTFA = async () => {
if (selectedUsers.size === 0) return;
const confirmMessage = `Disable 2FA requirement for ${selectedUsers.size} user${selectedUsers.size > 1 ? 's' : ''}?`;
if (!confirm(confirmMessage)) return;
try {
const userIds = Array.from(selectedUsers);
const response = await usersApi.bulkDisableTFA(userIds);
const result = response.data;
if (result.failed_count > 0) {
toast.error(`Disabled 2FA for ${result.success_count} users, ${result.failed_count} failed`);
} else {
toast.success(`Successfully disabled 2FA requirement for ${result.success_count} user${result.success_count > 1 ? 's' : ''}`);
}
setSelectedUsers(new Set());
fetchUsers();
} catch (error) {
console.error('Failed to disable 2FA:', error);
toast.error('Failed to disable 2FA requirement');
}
};
const getStatusBadge = (status: string) => {
switch (status) {
case 'active':
return <Badge variant="default" className="bg-green-600"><CheckCircle className="h-3 w-3 mr-1" />Active</Badge>;
case 'inactive':
return <Badge variant="secondary"><Clock className="h-3 w-3 mr-1" />Inactive</Badge>;
case 'suspended':
return <Badge variant="destructive"><XCircle className="h-3 w-3 mr-1" />Suspended</Badge>;
default:
return <Badge variant="secondary">{status}</Badge>;
}
};
const getUserTypeBadge = (type: string) => {
switch (type) {
case 'super_admin':
return <Badge className="bg-purple-600"><ShieldCheck className="h-3 w-3 mr-1" />Super Admin</Badge>;
case 'tenant_admin':
return <Badge className="bg-blue-600"><UserCog className="h-3 w-3 mr-1" />Tenant Admin</Badge>;
case 'tenant_user':
return <Badge variant="secondary"><User className="h-3 w-3 mr-1" />User</Badge>;
default:
return <Badge variant="secondary">{type}</Badge>;
}
};
const typeTabs = [
{ id: 'all', label: 'All Users', count: roleCounts.super_admin + roleCounts.tenant_admin + roleCounts.tenant_user },
{ id: 'super_admin', label: 'Super Admins', count: roleCounts.super_admin },
{ id: 'tenant_admin', label: 'Tenant Admins', count: roleCounts.tenant_admin },
{ id: 'tenant_user', label: 'Tenant Users', count: roleCounts.tenant_user },
];
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Users</h1>
<p className="text-muted-foreground">
Manage users and access permissions
</p>
<p className="text-sm text-amber-600 mt-1">
GT AI OS Community Edition: Limited to 50 users
</p>
</div>
<div className="flex space-x-2">
<Button variant="secondary" onClick={() => setBulkUploadDialogOpen(true)}>
<Upload className="h-4 w-4 mr-2" />
Bulk Upload
</Button>
<Button onClick={() => setAddDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Add User
</Button>
</div>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-1 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{roleCounts.super_admin + roleCounts.tenant_admin + roleCounts.tenant_user}</div>
<p className="text-xs text-muted-foreground">
{users.filter(u => u.status === 'active').length} active on this page
</p>
</CardContent>
</Card>
</div>
{/* Type Tabs */}
<div className="flex space-x-2 border-b">
{typeTabs.map(tab => (
<button
key={tab.id}
onClick={() => handleTypeFilterChange(tab.id)}
className={`px-4 py-2 border-b-2 transition-colors ${
typeFilter === tab.id
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
<span>{tab.label}</span>
<Badge variant="secondary" className="ml-2">{tab.count}</Badge>
</button>
))}
</div>
{/* Search and Filters */}
<div className="flex space-x-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search users by name, email, or tenant... (Press Enter)"
value={searchInput}
onChange={(e) => setSearchInput((e as React.ChangeEvent<HTMLInputElement>).target.value)}
onKeyPress={handleSearchKeyPress}
className="pl-10"
/>
</div>
<Button variant="secondary" onClick={handleSearch}>
<Search className="h-4 w-4 mr-2" />
Search
</Button>
</div>
{/* Bulk Actions */}
{selectedUsers.size > 0 && (
<Card className="bg-muted/50">
<CardContent className="flex items-center justify-between py-3">
<span className="text-sm">
{selectedUsers.size} user{selectedUsers.size > 1 ? 's' : ''} selected
</span>
<div className="flex flex-wrap gap-2">
<Button variant="secondary" size="sm" onClick={handleResetTFA}>
<RotateCcw className="h-4 w-4 mr-2" />
Reset 2FA
</Button>
<Button variant="default" size="sm" onClick={handleEnforceTFA}>
<ShieldCheck className="h-4 w-4 mr-2" />
Enforce 2FA
</Button>
<Button variant="secondary" size="sm" onClick={handleDisableTFA}>
<ShieldOff className="h-4 w-4 mr-2" />
Disable 2FA
</Button>
<Button variant="destructive" size="sm" onClick={handleDeleteSelected}>
<Trash2 className="h-4 w-4 mr-2" />
Delete Selected
</Button>
</div>
</CardContent>
</Card>
)}
{/* Users Table */}
{loading ? (
<div className="flex items-center justify-center h-64">
<Activity className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="border-b bg-muted/50">
<tr>
<th className="p-4 text-left">
<input
type="checkbox"
onChange={(e) => {
if (e.target.checked) {
handleSelectAll();
} else {
setSelectedUsers(new Set());
}
}}
checked={selectedUsers.size > 0 && selectedUsers.size === totalUsers}
/>
</th>
<th className="p-4 text-left font-medium">User</th>
<th className="p-4 text-left font-medium">Type</th>
<th className="p-4 text-left font-medium">Tenant</th>
<th className="p-4 text-left font-medium">Status</th>
<th className="p-4 text-left font-medium">2FA</th>
<th className="p-4 text-left font-medium">Actions</th>
</tr>
</thead>
<tbody>
{filteredUsers.map(user => (
<tr key={user.id} className="border-b hover:bg-muted/30">
<td className="p-4">
<input
type="checkbox"
checked={selectedUsers.has(user.id)}
onChange={(e) => {
const newSelected = new Set(selectedUsers);
if (e.target.checked) {
newSelected.add(user.id);
} else {
newSelected.delete(user.id);
}
setSelectedUsers(newSelected);
}}
/>
</td>
<td className="p-4">
<div>
<div className="font-medium">{user.full_name}</div>
<div className="text-sm text-muted-foreground flex items-center space-x-1">
<Mail className="h-3 w-3" />
<span>{user.email}</span>
</div>
</div>
</td>
<td className="p-4">
{getUserTypeBadge(user.user_type)}
</td>
<td className="p-4">
{user.tenant_name ? (
<div className="flex items-center space-x-1">
<Building2 className="h-4 w-4 text-muted-foreground" />
<span>{user.tenant_name}</span>
</div>
) : (
<span className="text-muted-foreground">System</span>
)}
</td>
<td className="p-4">
{getStatusBadge(user.status)}
</td>
<td className="p-4">
{user.tfa_required && user.tfa_enabled ? (
<Badge variant="default" className="bg-green-600">
<ShieldCheck className="h-3 w-3 mr-1" />
Enforced & Configured
</Badge>
) : user.tfa_required && !user.tfa_enabled ? (
<Badge variant="default" className="bg-orange-500">
<AlertTriangle className="h-3 w-3 mr-1" />
Enforced (Pending)
</Badge>
) : !user.tfa_required && user.tfa_enabled ? (
<Badge variant="default" className="bg-green-500">
<ShieldCheck className="h-3 w-3 mr-1" />
Enabled
</Badge>
) : (
<Badge variant="secondary">
<Lock className="h-3 w-3 mr-1" />
Disabled
</Badge>
)}
</td>
<td className="p-4">
<div className="flex space-x-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedUserId(user.id);
setEditDialogOpen(true);
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setUserToDelete({
id: user.id,
email: user.email,
full_name: user.full_name,
user_type: user.user_type,
});
setDeleteDialogOpen(true);
}}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
{/* Pagination */}
{!loading && totalUsers > 0 && (
<div className="flex items-center justify-between">
<div className="text-sm text-muted-foreground">
Showing {((currentPage - 1) * limit) + 1} to {Math.min(currentPage * limit, totalUsers)} of {totalUsers} users
</div>
<div className="flex items-center space-x-2">
<Button
variant="secondary"
size="sm"
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
disabled={currentPage === 1}
>
Previous
</Button>
<div className="flex items-center space-x-1">
{Array.from({ length: Math.ceil(totalUsers / limit) }, (_, i) => i + 1)
.filter(page => {
// Show first page, last page, current page, and pages around current
const totalPages = Math.ceil(totalUsers / limit);
return page === 1 ||
page === totalPages ||
(page >= currentPage - 1 && page <= currentPage + 1);
})
.map((page, index, array) => {
// Add ellipsis if there's a gap
const showEllipsisBefore = index > 0 && page - array[index - 1] > 1;
return (
<div key={page} className="flex items-center">
{showEllipsisBefore && <span className="px-2">...</span>}
<Button
variant={currentPage === page ? "default" : "ghost"}
size="sm"
onClick={() => setCurrentPage(page)}
className="min-w-[2.5rem]"
>
{page}
</Button>
</div>
);
})}
</div>
<Button
variant="secondary"
size="sm"
onClick={() => setCurrentPage(prev => Math.min(Math.ceil(totalUsers / limit), prev + 1))}
disabled={currentPage >= Math.ceil(totalUsers / limit)}
>
Next
</Button>
</div>
</div>
)}
{/* Dialogs */}
<AddUserDialog
open={addDialogOpen}
onOpenChange={setAddDialogOpen}
onUserAdded={fetchUsers}
/>
<EditUserDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
userId={selectedUserId}
onUserUpdated={fetchUsers}
/>
<DeleteUserDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
user={userToDelete}
onUserDeleted={fetchUsers}
/>
<BulkUploadDialog
open={bulkUploadDialogOpen}
onOpenChange={setBulkUploadDialogOpen}
onUploadComplete={fetchUsers}
/>
</div>
);
}