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,146 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import * as z from 'zod';
import { useAuthStore } from '@/stores/auth-store';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Loader2, Eye, EyeOff } from 'lucide-react';
const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(1, 'Password is required'),
});
type LoginForm = z.infer<typeof loginSchema>;
export default function LoginPage() {
const router = useRouter();
const { login, isLoading, isAuthenticated } = useAuthStore();
const [showPassword, setShowPassword] = useState(false);
const {
register,
handleSubmit,
formState: { errors, isSubmitting },
} = useForm<LoginForm>({
resolver: zodResolver(loginSchema),
});
// Redirect if already authenticated
useEffect(() => {
if (isAuthenticated) {
router.replace('/dashboard/tenants');
}
}, [isAuthenticated, router]);
const onSubmit = async (data: LoginForm) => {
const result = await login(data.email, data.password);
if (result.success) {
if (result.requiresTfa) {
// Redirect to TFA verification page
router.push('/auth/verify-tfa');
} else {
// Check if user has TFA setup pending
const { user } = useAuthStore.getState();
if (user?.tfa_setup_pending) {
router.push('/dashboard/settings');
} else {
router.push('/dashboard/tenants');
}
}
}
};
if (isAuthenticated) {
return null; // Prevent flash before redirect
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 p-4">
<Card className="w-full max-w-md">
<CardHeader className="space-y-1 text-center">
<div className="w-16 h-16 bg-primary rounded-lg flex items-center justify-center mx-auto mb-4">
<span className="text-2xl font-bold text-primary-foreground">GT</span>
</div>
<CardTitle className="text-2xl font-bold">GT 2.0 Control Panel</CardTitle>
<CardDescription>
Sign in to your super administrator account
</CardDescription>
<div className="mt-4 p-3 bg-amber-50 dark:bg-amber-950 border border-amber-200 dark:border-amber-800 rounded-md">
<p className="text-xs text-amber-800 dark:text-amber-200 text-center">
<strong>Super Admin Access Only</strong>
<br />
Only super administrators can access the Control Panel.
Tenant admins and users should use the main tenant application.
</p>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input
id="email"
type="email"
placeholder="Enter your email"
{...register('email')}
className={errors.email ? 'border-red-500' : ''}
/>
{errors.email && (
<p className="text-sm text-red-600">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="password">Password</Label>
<div className="relative">
<Input
id="password"
type={showPassword ? 'text' : 'password'}
placeholder="Enter your password"
{...register('password')}
className={errors.password ? 'border-red-500 pr-10' : 'pr-10'}
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOff className="h-4 w-4 text-gray-400" />
) : (
<Eye className="h-4 w-4 text-gray-400" />
)}
</button>
</div>
{errors.password && (
<p className="text-sm text-red-600">{errors.password.message}</p>
)}
</div>
<Button
type="submit"
className="w-full"
disabled={isSubmitting || isLoading}
>
{isSubmitting || isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Signing in...
</>
) : (
'Sign In'
)}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,293 @@
'use client';
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/auth-store';
import { verifyTFALogin, getTFASessionData, getTFAQRCodeBlob } from '@/services/tfa';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Loader2, Lock, AlertCircle, Copy } from 'lucide-react';
import toast from 'react-hot-toast';
export default function VerifyTFAPage() {
const router = useRouter();
const {
requiresTfa,
tfaConfigured,
completeTfaLogin,
logout,
} = useAuthStore();
const [code, setCode] = useState('');
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [isFetchingSession, setIsFetchingSession] = useState(true);
const [attempts, setAttempts] = useState(0);
// Session data fetched from server
const [qrCodeBlobUrl, setQrCodeBlobUrl] = useState<string | null>(null);
const [manualEntryKey, setManualEntryKey] = useState<string | null>(null);
useEffect(() => {
let blobUrl: string | null = null;
// Fetch TFA session data from server using HTTP-only cookie
const fetchSessionData = async () => {
if (!requiresTfa) {
router.push('/auth/login');
return;
}
try {
setIsFetchingSession(true);
// Fetch session metadata
const sessionData = await getTFASessionData();
if (sessionData.manual_entry_key) {
setManualEntryKey(sessionData.manual_entry_key);
}
// Fetch QR code as secure blob (if needed for setup)
if (!sessionData.tfa_configured) {
blobUrl = await getTFAQRCodeBlob();
setQrCodeBlobUrl(blobUrl);
}
} catch (err: any) {
console.error('Failed to fetch TFA session data:', err);
setError('Session expired. Please login again.');
setTimeout(() => router.push('/auth/login'), 2000);
} finally {
setIsFetchingSession(false);
}
};
fetchSessionData();
// Cleanup: revoke blob URL on unmount using local variable
return () => {
if (blobUrl) {
URL.revokeObjectURL(blobUrl);
}
};
}, [requiresTfa, router]);
const handleVerify = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
// Validate code format (6 digits)
if (!/^\d{6}$/.test(code)) {
setError('Please enter a valid 6-digit code');
return;
}
setIsLoading(true);
try {
// Session cookie automatically sent with request
const result = await verifyTFALogin(code);
if (result.success && result.access_token) {
// Extract user data from response
const user = result.user;
// Update auth store with token and user
completeTfaLogin(result.access_token, user);
// Redirect to tenant page
router.push('/dashboard/tenants');
} else {
throw new Error(result.message || 'Verification failed');
}
} catch (err: any) {
const newAttempts = attempts + 1;
setAttempts(newAttempts);
if (newAttempts >= 5) {
setError('Too many attempts. Please wait 60 seconds and try again.');
} else {
setError(err.message || 'Invalid verification code. Please try again.');
}
setCode('');
setIsLoading(false);
}
};
const handleCancel = () => {
logout();
router.push('/auth/login');
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.success('Copied to clipboard');
};
// Show loading while fetching session data
if (isFetchingSession) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800">
<div className="text-center">
<Loader2 className="w-12 h-12 mx-auto mb-4 animate-spin text-primary" />
<p className="text-muted-foreground">Loading TFA setup...</p>
</div>
</div>
);
}
if (!requiresTfa) {
return null; // Will redirect via useEffect
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 p-4">
<Card className="w-full max-w-md">
<CardHeader className="text-center space-y-1">
<div className="w-16 h-16 bg-primary rounded-lg flex items-center justify-center mx-auto mb-4">
<Lock className="h-8 w-8 text-primary-foreground" />
</div>
<CardTitle className="text-2xl font-bold">
{tfaConfigured ? 'Two-Factor Authentication' : 'Setup Two-Factor Authentication'}
</CardTitle>
<CardDescription>
{tfaConfigured
? 'Enter the 6-digit code from your authenticator app'
: 'Your administrator requires 2FA for your account'}
</CardDescription>
</CardHeader>
<CardContent>
{/* Mode A: Setup (tfa_configured=false) */}
{!tfaConfigured && qrCodeBlobUrl && (
<div className="mb-6 space-y-4">
<div>
<h3 className="text-sm font-semibold mb-3">Scan QR Code</h3>
{/* QR Code Display (secure blob URL - TOTP secret never in JavaScript) */}
<div className="bg-white p-4 rounded-lg border-2 border-border mb-4 flex justify-center">
<img
src={qrCodeBlobUrl}
alt="QR Code"
className="w-48 h-48"
/>
</div>
</div>
{/* Manual Entry Key */}
{manualEntryKey && (
<div>
<label className="block text-sm font-medium mb-2">
Manual Entry Key
</label>
<div className="flex items-center gap-2">
<code className="flex-1 px-3 py-2 bg-muted border border-border rounded-lg text-sm font-mono">
{manualEntryKey}
</code>
<Button
variant="outline"
size="sm"
onClick={() => copyToClipboard(manualEntryKey.replace(/\s/g, ''))}
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
)}
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<p className="text-sm font-semibold mb-2 text-blue-900 dark:text-blue-100">
Instructions:
</p>
<ol className="text-sm text-blue-800 dark:text-blue-200 ml-4 list-decimal space-y-1">
<li>Download Google Authenticator or any TOTP app</li>
<li>Scan the QR code or enter the manual key as shown above</li>
<li>Enter the 6-digit code below to complete setup</li>
</ol>
</div>
</div>
)}
{/* Code Input (both modes) */}
<form onSubmit={handleVerify} className="space-y-4">
<div>
<label className="block text-sm font-medium mb-2">
6-Digit Code
</label>
<Input
type="text"
value={code}
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
maxLength={6}
autoFocus
disabled={isLoading || attempts >= 5}
className="text-center text-2xl tracking-widest font-mono"
/>
</div>
{error && (
<div className="bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-lg p-3">
<div className="flex items-center">
<AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400 mr-2" />
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
</div>
</div>
)}
{attempts > 0 && attempts < 5 && (
<p className="text-sm text-muted-foreground text-center">
Attempts remaining: {5 - attempts}
</p>
)}
<div className="flex gap-3">
<Button
type="submit"
className="flex-1"
disabled={isLoading || code.length !== 6 || attempts >= 5}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Verifying...
</>
) : (
tfaConfigured ? 'Verify' : 'Verify and Complete Setup'
)}
</Button>
{/* Only show cancel if TFA is already configured (optional flow) */}
{tfaConfigured && (
<Button
type="button"
variant="outline"
onClick={handleCancel}
disabled={isLoading}
>
Cancel
</Button>
)}
</div>
</form>
{/* No cancel button for mandatory setup (Mode A) */}
{!tfaConfigured && (
<div className="mt-4 text-center">
<p className="text-xs text-muted-foreground">
2FA is required for your account. Contact your administrator if you need assistance.
</p>
</div>
)}
</CardContent>
</Card>
{/* Security Info */}
<div className="absolute bottom-4 left-0 right-0 text-center text-sm text-muted-foreground">
<p>GT 2.0 Control Panel Enterprise Security</p>
</div>
</div>
);
}

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

View File

@@ -0,0 +1,130 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--card: 0 0% 100%;
--card-foreground: 222.2 84% 4.9%;
--popover: 0 0% 100%;
--popover-foreground: 222.2 84% 4.9%;
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
--secondary: 210 40% 96%;
--secondary-foreground: 222.2 84% 4.9%;
--muted: 210 40% 96%;
--muted-foreground: 215.4 16.3% 46.9%;
--accent: 210 40% 96%;
--accent-foreground: 222.2 84% 4.9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
--card: 222.2 84% 4.9%;
--card-foreground: 210 40% 98%;
--popover: 222.2 84% 4.9%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222.2 84% 4.9%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 224.3 76.3% 94.1%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}
/* Custom GT 2.0 styles */
.gt-gradient {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.gt-card {
@apply bg-card text-card-foreground border border-border rounded-lg shadow-sm;
}
.gt-sidebar {
@apply bg-muted/50 border-r border-border;
}
.gt-nav-item {
@apply flex items-center space-x-3 px-3 py-2 rounded-md text-sm font-medium transition-colors;
}
.gt-nav-item-active {
@apply bg-primary text-primary-foreground;
}
.gt-nav-item-inactive {
@apply text-muted-foreground hover:bg-accent hover:text-accent-foreground;
}
.gt-status-badge {
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
}
.gt-status-active {
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300;
}
.gt-status-pending {
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300;
}
.gt-status-suspended {
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300;
}
.gt-status-deploying {
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300;
}
/* Loading animations */
.gt-loading {
@apply animate-pulse;
}
.gt-loading-skeleton {
@apply bg-muted rounded;
}
/* Table styles */
.gt-table {
@apply w-full border-collapse;
}
.gt-table th {
@apply px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider border-b border-border;
}
.gt-table td {
@apply px-4 py-4 whitespace-nowrap text-sm border-b border-border;
}
.gt-table tr:hover {
@apply bg-muted/50;
}

View File

@@ -0,0 +1,9 @@
import { NextResponse } from 'next/server';
export async function GET() {
return NextResponse.json({
status: 'healthy',
service: 'gt2-control-panel-frontend',
timestamp: new Date().toISOString()
});
}

View File

@@ -0,0 +1,56 @@
import type { Metadata } from 'next';
import { Inter } from 'next/font/google';
import './globals.css';
import { Providers } from '@/lib/providers';
import { Toaster } from 'react-hot-toast';
import Script from 'next/script';
const inter = Inter({ subsets: ['latin'] });
export const metadata: Metadata = {
title: 'GT 2.0 Control Panel',
description: 'Enterprise AI as a Service Platform - Control Panel',
icons: {
icon: '/favicon.ico',
},
};
export default function RootLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<html lang="en">
<head>
<Script id="disable-console" strategy="beforeInteractive">
{`
// Disable console logs in production
if (typeof window !== 'undefined' && '${process.env.NEXT_PUBLIC_ENVIRONMENT}' === 'production') {
const noop = function() {};
['log', 'debug', 'info', 'warn'].forEach(function(method) {
console[method] = noop;
});
}
`}
</Script>
</head>
<body className={inter.className}>
<Providers>
{children}
<Toaster
position="top-right"
toastOptions={{
duration: 4000,
style: {
background: 'hsl(var(--card))',
color: 'hsl(var(--card-foreground))',
border: '1px solid hsl(var(--border))',
},
}}
/>
</Providers>
</body>
</html>
);
}

View File

@@ -0,0 +1,39 @@
'use client';
import { useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/auth-store';
import { Loader2 } from 'lucide-react';
export default function HomePage() {
const router = useRouter();
const { user, isLoading, checkAuth } = useAuthStore();
useEffect(() => {
checkAuth();
}, [checkAuth]);
useEffect(() => {
if (!isLoading) {
if (user) {
router.replace('/dashboard/tenants');
} else {
router.replace('/auth/login');
}
}
}, [user, isLoading, router]);
return (
<div className="flex items-center justify-center min-h-screen bg-background">
<div className="flex flex-col items-center space-y-4">
<div className="w-16 h-16 bg-primary rounded-lg flex items-center justify-center">
<span className="text-2xl font-bold text-primary-foreground">GT</span>
</div>
<div className="flex items-center space-x-2">
<Loader2 className="h-4 w-4 animate-spin" />
<span className="text-muted-foreground">Loading GT 2.0...</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,498 @@
'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 {
Plus,
Search,
Filter,
Brain,
Database,
GitBranch,
Webhook,
ExternalLink,
GraduationCap,
RefreshCw,
Settings,
CheckCircle,
AlertTriangle,
XCircle,
Activity,
Zap,
Shield,
Users,
Building2,
MoreVertical,
} from 'lucide-react';
import { useAuthStore } from '@/stores/auth-store';
import { useRouter } from 'next/navigation';
interface Resource {
id: number;
uuid: string;
name: string;
description?: string;
resource_type: string;
resource_subtype?: string;
provider: string;
model_name?: string;
personalization_mode: string;
health_status: string;
is_active: boolean;
priority: number;
max_requests_per_minute: number;
max_tokens_per_request: number;
cost_per_1k_tokens: number;
last_health_check?: string;
created_at: string;
updated_at: string;
}
export default function ResourcesPage() {
const { user } = useAuthStore();
const router = useRouter();
const [resources, setResources] = useState<Resource[]>([]);
const [filteredResources, setFilteredResources] = useState<Resource[]>([]);
const [loading, setLoading] = useState(true);
const [selectedTab, setSelectedTab] = useState('all');
const [searchQuery, setSearchQuery] = useState('');
const [selectedResources, setSelectedResources] = useState<Set<number>>(new Set());
// Mock data for development
useEffect(() => {
const mockResources: Resource[] = [
{
id: 1,
uuid: '123e4567-e89b-12d3-a456-426614174000',
name: 'GPT-4 Turbo',
description: 'Advanced language model for complex tasks',
resource_type: 'ai_ml',
resource_subtype: 'llm',
provider: 'openai',
model_name: 'gpt-4-turbo-preview',
personalization_mode: 'shared',
health_status: 'healthy',
is_active: true,
priority: 100,
max_requests_per_minute: 500,
max_tokens_per_request: 8000,
cost_per_1k_tokens: 0.03,
last_health_check: new Date().toISOString(),
created_at: '2024-01-01T00:00:00Z',
updated_at: new Date().toISOString(),
},
{
id: 2,
uuid: '223e4567-e89b-12d3-a456-426614174001',
name: 'Llama 3.1 70B (Groq)',
description: 'Fast inference via Groq Cloud',
resource_type: 'ai_ml',
resource_subtype: 'llm',
provider: 'groq',
model_name: 'llama-3.1-70b-versatile',
personalization_mode: 'shared',
health_status: 'healthy',
is_active: true,
priority: 90,
max_requests_per_minute: 1000,
max_tokens_per_request: 4096,
cost_per_1k_tokens: 0.008,
last_health_check: new Date().toISOString(),
created_at: '2024-01-02T00:00:00Z',
updated_at: new Date().toISOString(),
},
{
id: 3,
uuid: '323e4567-e89b-12d3-a456-426614174002',
name: 'BGE-M3 Embeddings',
description: '1024-dimension embedding model on GPU cluster',
resource_type: 'ai_ml',
resource_subtype: 'embedding',
provider: 'local',
model_name: 'BAAI/bge-m3',
personalization_mode: 'shared',
health_status: 'healthy',
is_active: true,
priority: 95,
max_requests_per_minute: 2000,
max_tokens_per_request: 512,
cost_per_1k_tokens: 0.0001,
last_health_check: new Date().toISOString(),
created_at: '2024-01-03T00:00:00Z',
updated_at: new Date().toISOString(),
},
{
id: 4,
uuid: '423e4567-e89b-12d3-a456-426614174003',
name: 'ChromaDB Vector Store',
description: 'Encrypted vector database with user isolation',
resource_type: 'rag_engine',
resource_subtype: 'vector_database',
provider: 'local',
model_name: 'chromadb',
personalization_mode: 'user_scoped',
health_status: 'healthy',
is_active: true,
priority: 100,
max_requests_per_minute: 5000,
max_tokens_per_request: 0,
cost_per_1k_tokens: 0,
last_health_check: new Date().toISOString(),
created_at: '2024-01-04T00:00:00Z',
updated_at: new Date().toISOString(),
},
{
id: 5,
uuid: '523e4567-e89b-12d3-a456-426614174004',
name: 'Document Processor',
description: 'Unstructured.io chunking engine',
resource_type: 'rag_engine',
resource_subtype: 'document_processor',
provider: 'local',
personalization_mode: 'shared',
health_status: 'healthy',
is_active: true,
priority: 100,
max_requests_per_minute: 100,
max_tokens_per_request: 0,
cost_per_1k_tokens: 0,
last_health_check: new Date().toISOString(),
created_at: '2024-01-05T00:00:00Z',
updated_at: new Date().toISOString(),
},
{
id: 6,
uuid: '623e4567-e89b-12d3-a456-426614174005',
name: 'Research Agent',
description: 'Multi-step research workflow orchestrator',
resource_type: 'agentic_workflow',
resource_subtype: 'single_agent',
provider: 'local',
personalization_mode: 'user_scoped',
health_status: 'healthy',
is_active: true,
priority: 90,
max_requests_per_minute: 50,
max_tokens_per_request: 0,
cost_per_1k_tokens: 0,
last_health_check: new Date().toISOString(),
created_at: '2024-01-06T00:00:00Z',
updated_at: new Date().toISOString(),
},
{
id: 7,
uuid: '723e4567-e89b-12d3-a456-426614174006',
name: 'GitHub Connector',
description: 'GitHub API integration for DevOps workflows',
resource_type: 'app_integration',
resource_subtype: 'development',
provider: 'custom',
personalization_mode: 'user_scoped',
health_status: 'unhealthy',
is_active: true,
priority: 80,
max_requests_per_minute: 60,
max_tokens_per_request: 0,
cost_per_1k_tokens: 0,
last_health_check: new Date(Date.now() - 3600000).toISOString(),
created_at: '2024-01-07T00:00:00Z',
updated_at: new Date().toISOString(),
},
{
id: 8,
uuid: '823e4567-e89b-12d3-a456-426614174007',
name: 'Canvas LMS',
description: 'Educational platform integration',
resource_type: 'external_service',
resource_subtype: 'educational',
provider: 'canvas',
personalization_mode: 'user_scoped',
health_status: 'healthy',
is_active: true,
priority: 85,
max_requests_per_minute: 100,
max_tokens_per_request: 0,
cost_per_1k_tokens: 0,
last_health_check: new Date().toISOString(),
created_at: '2024-01-08T00:00:00Z',
updated_at: new Date().toISOString(),
},
{
id: 9,
uuid: '923e4567-e89b-12d3-a456-426614174008',
name: 'Strategic Chess Engine',
description: 'AI-powered chess with learning analytics',
resource_type: 'ai_literacy',
resource_subtype: 'strategic_game',
provider: 'local',
personalization_mode: 'user_scoped',
health_status: 'healthy',
is_active: true,
priority: 70,
max_requests_per_minute: 200,
max_tokens_per_request: 0,
cost_per_1k_tokens: 0,
last_health_check: new Date().toISOString(),
created_at: '2024-01-09T00:00:00Z',
updated_at: new Date().toISOString(),
},
];
setResources(mockResources);
setFilteredResources(mockResources);
setLoading(false);
}, []);
// Filter resources based on tab and search
useEffect(() => {
let filtered = resources;
// Filter by resource type
if (selectedTab !== 'all') {
filtered = filtered.filter(r => r.resource_type === selectedTab);
}
// Filter by search query
if (searchQuery) {
filtered = filtered.filter(r =>
r.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
r.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
r.provider.toLowerCase().includes(searchQuery.toLowerCase())
);
}
setFilteredResources(filtered);
}, [selectedTab, searchQuery, resources]);
const getResourceIcon = (type: string) => {
switch (type) {
case 'ai_ml': return <Brain className="h-5 w-5" />;
case 'rag_engine': return <Database className="h-5 w-5" />;
case 'agentic_workflow': return <GitBranch className="h-5 w-5" />;
case 'app_integration': return <Webhook className="h-5 w-5" />;
case 'external_service': return <ExternalLink className="h-5 w-5" />;
case 'ai_literacy': return <GraduationCap className="h-5 w-5" />;
default: return <Zap className="h-5 w-5" />;
}
};
const getHealthBadge = (status: string) => {
switch (status) {
case 'healthy':
return <Badge variant="default" className="bg-green-600"><CheckCircle className="h-3 w-3 mr-1" />Healthy</Badge>;
case 'unhealthy':
return <Badge variant="destructive"><XCircle className="h-3 w-3 mr-1" />Unhealthy</Badge>;
default:
return <Badge variant="secondary"><AlertTriangle className="h-3 w-3 mr-1" />Unknown</Badge>;
}
};
const getPersonalizationBadge = (mode: string) => {
switch (mode) {
case 'shared':
return <Badge variant="secondary"><Users className="h-3 w-3 mr-1" />Shared</Badge>;
case 'user_scoped':
return <Badge variant="secondary"><Shield className="h-3 w-3 mr-1" />User-Scoped</Badge>;
case 'session_based':
return <Badge variant="secondary"><Activity className="h-3 w-3 mr-1" />Session-Based</Badge>;
default:
return <Badge variant="secondary">{mode}</Badge>;
}
};
const resourceTabs = [
{ id: 'all', label: 'All Resources', count: resources.length },
{ id: 'ai_ml', label: 'AI/ML Models', icon: <Brain className="h-4 w-4" />, count: resources.filter(r => r.resource_type === 'ai_ml').length },
{ id: 'rag_engine', label: 'RAG Engines', icon: <Database className="h-4 w-4" />, count: resources.filter(r => r.resource_type === 'rag_engine').length },
{ id: 'agentic_workflow', label: 'Agents', icon: <GitBranch className="h-4 w-4" />, count: resources.filter(r => r.resource_type === 'agentic_workflow').length },
{ id: 'app_integration', label: 'Integrations', icon: <Webhook className="h-4 w-4" />, count: resources.filter(r => r.resource_type === 'app_integration').length },
{ id: 'external_service', label: 'External', icon: <ExternalLink className="h-4 w-4" />, count: resources.filter(r => r.resource_type === 'external_service').length },
{ id: 'ai_literacy', label: 'AI Literacy', icon: <GraduationCap className="h-4 w-4" />, count: resources.filter(r => r.resource_type === 'ai_literacy').length },
];
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Resource Management</h1>
<p className="text-muted-foreground">
Manage all GT 2.0 resources across six comprehensive families
</p>
</div>
<div className="flex space-x-2">
<Button variant="secondary">
<RefreshCw className="h-4 w-4 mr-2" />
Health Check All
</Button>
<Button>
<Plus className="h-4 w-4 mr-2" />
Add Resource
</Button>
</div>
</div>
{/* Resource Type Tabs */}
<div className="flex space-x-2 border-b">
{resourceTabs.map(tab => (
<button
key={tab.id}
onClick={() => setSelectedTab(tab.id)}
className={`flex items-center space-x-2 px-4 py-2 border-b-2 transition-colors ${
selectedTab === tab.id
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
{tab.icon}
<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 resources by name, provider, or description..."
value={searchQuery}
onChange={(e) => setSearchQuery((e as React.ChangeEvent<HTMLInputElement>).target.value)}
className="pl-10"
/>
</div>
<Button variant="secondary">
<Filter className="h-4 w-4 mr-2" />
Filters
</Button>
</div>
{/* Bulk Actions */}
{selectedResources.size > 0 && (
<Card className="bg-muted/50">
<CardContent className="flex items-center justify-between py-3">
<span className="text-sm">
{selectedResources.size} resource{selectedResources.size > 1 ? 's' : ''} selected
</span>
<div className="flex space-x-2">
<Button variant="secondary" size="sm">
<Building2 className="h-4 w-4 mr-2" />
Assign to Tenants
</Button>
<Button variant="secondary" size="sm">
<RefreshCw className="h-4 w-4 mr-2" />
Health Check
</Button>
<Button variant="secondary" size="sm" className="text-destructive">
Disable
</Button>
</div>
</CardContent>
</Card>
)}
{/* Resources Grid */}
{loading ? (
<div className="flex items-center justify-center h-64">
<RefreshCw 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-4">
{filteredResources.map(resource => (
<Card key={resource.id} className="hover:shadow-md transition-shadow">
<CardHeader className="pb-3">
<div className="flex items-start justify-between">
<div className="flex items-center space-x-2">
{getResourceIcon(resource.resource_type)}
<div>
<CardTitle className="text-lg">{resource.name}</CardTitle>
<p className="text-xs text-muted-foreground">{resource.provider}</p>
</div>
</div>
<div className="flex items-center space-x-2">
<input
type="checkbox"
checked={selectedResources.has(resource.id)}
onChange={(e) => {
const newSelected = new Set(selectedResources);
if (e.target.checked) {
newSelected.add(resource.id);
} else {
newSelected.delete(resource.id);
}
setSelectedResources(newSelected);
}}
className="h-4 w-4"
/>
<Button variant="ghost" size="sm">
<MoreVertical className="h-4 w-4" />
</Button>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3">
{resource.description && (
<p className="text-sm text-muted-foreground">{resource.description}</p>
)}
<div className="flex flex-wrap gap-2">
{getHealthBadge(resource.health_status)}
{getPersonalizationBadge(resource.personalization_mode)}
{resource.model_name && (
<Badge variant="secondary" className="text-xs">{resource.model_name}</Badge>
)}
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
<div>
<span className="text-muted-foreground">Rate Limit:</span>
<p className="font-medium">{resource.max_requests_per_minute}/min</p>
</div>
{resource.max_tokens_per_request > 0 && (
<div>
<span className="text-muted-foreground">Max Tokens:</span>
<p className="font-medium">{resource.max_tokens_per_request.toLocaleString()}</p>
</div>
)}
{resource.cost_per_1k_tokens > 0 && (
<div>
<span className="text-muted-foreground">Cost/1K:</span>
<p className="font-medium">${resource.cost_per_1k_tokens}</p>
</div>
)}
<div>
<span className="text-muted-foreground">Priority:</span>
<p className="font-medium">{resource.priority}</p>
</div>
</div>
{resource.last_health_check && (
<div className="pt-2 border-t">
<p className="text-xs text-muted-foreground">
Last checked: {new Date(resource.last_health_check).toLocaleTimeString()}
</p>
</div>
)}
<div className="flex space-x-2 pt-2">
<Button variant="secondary" size="sm" className="flex-1">
<Settings className="h-3 w-3 mr-1" />
Configure
</Button>
<Button variant="secondary" size="sm" className="flex-1">
<Building2 className="h-3 w-3 mr-1" />
Assign
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,498 @@
'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 {
Plus,
Search,
Building2,
Users,
Cpu,
Activity,
CheckCircle,
Clock,
XCircle,
AlertTriangle,
Play,
Pause,
Archive,
Settings,
Eye,
Rocket,
Timer,
Shield,
Database,
Cloud,
MoreVertical,
} from 'lucide-react';
interface Tenant {
id: number;
name: string;
domain: string;
template: string;
status: 'active' | 'pending' | 'suspended' | 'archived';
max_users: number;
current_users: number;
namespace: string;
resource_count: number;
created_at: string;
last_activity?: string;
deployment_status?: 'deployed' | 'deploying' | 'failed' | 'not_deployed';
storage_used_gb?: number;
api_calls_today?: number;
}
export default function TenantsPage() {
const [tenants, setTenants] = useState<Tenant[]>([]);
const [filteredTenants, setFilteredTenants] = useState<Tenant[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState('all');
const [selectedTenants, setSelectedTenants] = useState<Set<number>>(new Set());
// Mock data for development
useEffect(() => {
const mockTenants: Tenant[] = [
{
id: 1,
name: 'Acme Corporation',
domain: 'acme',
template: 'enterprise',
status: 'active',
max_users: 500,
current_users: 247,
namespace: 'gt-tenant-acme',
resource_count: 12,
created_at: '2024-01-15T10:00:00Z',
last_activity: new Date().toISOString(),
deployment_status: 'deployed',
storage_used_gb: 45.2,
api_calls_today: 15234,
},
{
id: 2,
name: 'TechStart Inc',
domain: 'techstart',
template: 'startup',
status: 'pending',
max_users: 100,
current_users: 0,
namespace: 'gt-tenant-techstart',
resource_count: 8,
created_at: '2024-01-14T14:30:00Z',
deployment_status: 'deploying',
storage_used_gb: 0,
api_calls_today: 0,
},
{
id: 3,
name: 'Global Solutions',
domain: 'global',
template: 'enterprise',
status: 'active',
max_users: 1000,
current_users: 623,
namespace: 'gt-tenant-global',
resource_count: 24,
created_at: '2024-01-13T09:15:00Z',
last_activity: new Date(Date.now() - 3600000).toISOString(),
deployment_status: 'deployed',
storage_used_gb: 128.7,
api_calls_today: 42156,
},
{
id: 4,
name: 'Education First',
domain: 'edufirst',
template: 'education',
status: 'active',
max_users: 2000,
current_users: 1456,
namespace: 'gt-tenant-edufirst',
resource_count: 18,
created_at: '2024-01-10T11:00:00Z',
last_activity: new Date(Date.now() - 600000).toISOString(),
deployment_status: 'deployed',
storage_used_gb: 89.3,
api_calls_today: 28934,
},
{
id: 5,
name: 'CyberDefense Corp',
domain: 'cyberdef',
template: 'cybersecurity',
status: 'active',
max_users: 300,
current_users: 189,
namespace: 'gt-tenant-cyberdef',
resource_count: 21,
created_at: '2024-01-08T08:45:00Z',
last_activity: new Date(Date.now() - 1800000).toISOString(),
deployment_status: 'deployed',
storage_used_gb: 67.4,
api_calls_today: 19876,
},
{
id: 6,
name: 'Beta Testers LLC',
domain: 'betatest',
template: 'development',
status: 'suspended',
max_users: 50,
current_users: 12,
namespace: 'gt-tenant-betatest',
resource_count: 5,
created_at: '2024-01-05T15:20:00Z',
last_activity: new Date(Date.now() - 86400000).toISOString(),
deployment_status: 'deployed',
storage_used_gb: 12.1,
api_calls_today: 0,
},
];
setTenants(mockTenants);
setFilteredTenants(mockTenants);
setLoading(false);
}, []);
// Filter tenants based on search and status
useEffect(() => {
let filtered = tenants;
// Filter by status
if (statusFilter !== 'all') {
filtered = filtered.filter(t => t.status === statusFilter);
}
// Filter by search query
if (searchQuery) {
filtered = filtered.filter(t =>
t.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
t.domain.toLowerCase().includes(searchQuery.toLowerCase()) ||
t.template.toLowerCase().includes(searchQuery.toLowerCase())
);
}
setFilteredTenants(filtered);
}, [statusFilter, searchQuery, tenants]);
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 'pending':
return <Badge variant="secondary"><Clock className="h-3 w-3 mr-1" />Pending</Badge>;
case 'suspended':
return <Badge variant="destructive"><Pause className="h-3 w-3 mr-1" />Suspended</Badge>;
case 'archived':
return <Badge variant="secondary"><Archive className="h-3 w-3 mr-1" />Archived</Badge>;
default:
return <Badge variant="secondary">{status}</Badge>;
}
};
const getDeploymentBadge = (status?: string) => {
switch (status) {
case 'deployed':
return <Badge variant="secondary" className="text-green-600"><Cloud className="h-3 w-3 mr-1" />Deployed</Badge>;
case 'deploying':
return <Badge variant="secondary" className="text-blue-600"><Rocket className="h-3 w-3 mr-1" />Deploying</Badge>;
case 'failed':
return <Badge variant="secondary" className="text-red-600"><XCircle className="h-3 w-3 mr-1" />Failed</Badge>;
default:
return <Badge variant="secondary"><AlertTriangle className="h-3 w-3 mr-1" />Not Deployed</Badge>;
}
};
const getTemplateBadge = (template: string) => {
const colors: Record<string, string> = {
enterprise: 'bg-purple-600',
startup: 'bg-blue-600',
education: 'bg-green-600',
cybersecurity: 'bg-red-600',
development: 'bg-yellow-600',
};
return (
<Badge className={colors[template] || 'bg-gray-600'}>
{template.charAt(0).toUpperCase() + template.slice(1)}
</Badge>
);
};
const statusTabs = [
{ id: 'all', label: 'All Tenants', count: tenants.length },
{ id: 'active', label: 'Active', count: tenants.filter(t => t.status === 'active').length },
{ id: 'pending', label: 'Pending', count: tenants.filter(t => t.status === 'pending').length },
{ id: 'suspended', label: 'Suspended', count: tenants.filter(t => t.status === 'suspended').length },
];
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">Tenant Management</h1>
<p className="text-muted-foreground">
Manage tenant deployments with 5-minute onboarding
</p>
</div>
<div className="flex space-x-2">
<Button variant="secondary">
<Timer className="h-4 w-4 mr-2" />
Bulk Deploy
</Button>
<Button className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700">
<Rocket className="h-4 w-4 mr-2" />
5-Min Onboarding
</Button>
</div>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Total Tenants</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{tenants.length}</div>
<p className="text-xs text-muted-foreground">
{tenants.filter(t => t.status === 'active').length} active
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{tenants.reduce((sum, t) => sum + t.current_users, 0).toLocaleString()}
</div>
<p className="text-xs text-muted-foreground">
Across all tenants
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Storage Used</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{(tenants.reduce((sum, t) => sum + (t.storage_used_gb || 0), 0) / 1024).toFixed(1)} TB
</div>
<p className="text-xs text-muted-foreground">
Total consumption
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">API Calls Today</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{tenants.reduce((sum, t) => sum + (t.api_calls_today || 0), 0).toLocaleString()}
</div>
<p className="text-xs text-muted-foreground">
All tenants combined
</p>
</CardContent>
</Card>
</div>
{/* Status Tabs */}
<div className="flex space-x-2 border-b">
{statusTabs.map(tab => (
<button
key={tab.id}
onClick={() => setStatusFilter(tab.id)}
className={`px-4 py-2 border-b-2 transition-colors ${
statusFilter === 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 tenants by name, domain, or template..."
value={searchQuery}
onChange={(e) => setSearchQuery((e as React.ChangeEvent<HTMLInputElement>).target.value)}
className="pl-10"
/>
</div>
</div>
{/* Bulk Actions */}
{selectedTenants.size > 0 && (
<Card className="bg-muted/50">
<CardContent className="flex items-center justify-between py-3">
<span className="text-sm">
{selectedTenants.size} tenant{selectedTenants.size > 1 ? 's' : ''} selected
</span>
<div className="flex space-x-2">
<Button variant="secondary" size="sm">
<Cpu className="h-4 w-4 mr-2" />
Assign Resources
</Button>
<Button variant="secondary" size="sm">
<Play className="h-4 w-4 mr-2" />
Deploy All
</Button>
<Button variant="secondary" size="sm" className="text-destructive">
<Pause className="h-4 w-4 mr-2" />
Suspend
</Button>
</div>
</CardContent>
</Card>
)}
{/* Tenants 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) {
setSelectedTenants(new Set(filteredTenants.map(t => t.id)));
} else {
setSelectedTenants(new Set());
}
}}
checked={selectedTenants.size === filteredTenants.length && filteredTenants.length > 0}
/>
</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">Template</th>
<th className="p-4 text-left font-medium">Users</th>
<th className="p-4 text-left font-medium">Resources</th>
<th className="p-4 text-left font-medium">Usage</th>
<th className="p-4 text-left font-medium">Activity</th>
<th className="p-4 text-left font-medium">Actions</th>
</tr>
</thead>
<tbody>
{filteredTenants.map(tenant => (
<tr key={tenant.id} className="border-b hover:bg-muted/30">
<td className="p-4">
<input
type="checkbox"
checked={selectedTenants.has(tenant.id)}
onChange={(e) => {
const newSelected = new Set(selectedTenants);
if (e.target.checked) {
newSelected.add(tenant.id);
} else {
newSelected.delete(tenant.id);
}
setSelectedTenants(newSelected);
}}
/>
</td>
<td className="p-4">
<div>
<div className="font-medium">{tenant.name}</div>
<div className="text-sm text-muted-foreground">{tenant.domain}.gt2.com</div>
<div className="text-xs text-muted-foreground mt-1">{tenant.namespace}</div>
</div>
</td>
<td className="p-4">
<div className="space-y-1">
{getStatusBadge(tenant.status)}
{getDeploymentBadge(tenant.deployment_status)}
</div>
</td>
<td className="p-4">
{getTemplateBadge(tenant.template)}
</td>
<td className="p-4">
<div>
<div className="font-medium">{tenant.current_users}</div>
<div className="text-xs text-muted-foreground">of {tenant.max_users}</div>
<div className="w-full bg-secondary rounded-full h-1.5 mt-1">
<div
className="bg-primary h-1.5 rounded-full"
style={{ width: `${(tenant.current_users / tenant.max_users) * 100}%` }}
/>
</div>
</div>
</td>
<td className="p-4">
<div className="flex items-center space-x-1">
<Cpu className="h-4 w-4 text-muted-foreground" />
<span className="font-medium">{tenant.resource_count}</span>
</div>
</td>
<td className="p-4">
<div className="space-y-1 text-sm">
{tenant.storage_used_gb && (
<div className="flex items-center space-x-1">
<Database className="h-3 w-3 text-muted-foreground" />
<span>{tenant.storage_used_gb.toFixed(1)} GB</span>
</div>
)}
{tenant.api_calls_today && (
<div className="flex items-center space-x-1">
<Activity className="h-3 w-3 text-muted-foreground" />
<span>{tenant.api_calls_today.toLocaleString()}</span>
</div>
)}
</div>
</td>
<td className="p-4">
{tenant.last_activity && (
<div className="text-sm text-muted-foreground">
{new Date(tenant.last_activity).toLocaleTimeString()}
</div>
)}
</td>
<td className="p-4">
<div className="flex space-x-1">
<Button variant="ghost" size="sm">
<Eye className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm">
<Settings className="h-4 w-4" />
</Button>
<Button variant="ghost" size="sm">
<MoreVertical className="h-4 w-4" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,463 @@
'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,
} from 'lucide-react';
interface UserType {
id: number;
email: string;
full_name: string;
user_type: 'gt_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;
api_calls_today?: number;
active_sessions?: number;
}
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());
// 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 real users from API - GT 2.0 "No Mocks" principle
useEffect(() => {
fetchUsers();
}, []);
const fetchUsers = async () => {
try {
setLoading(true);
const response = await usersApi.list(1, 100);
const userData = response.data?.users || response.data?.data || [];
// Map API response to expected format
const mappedUsers: UserType[] = userData.map((user: any) => ({
...user,
status: user.is_active ? 'active' : 'suspended',
api_calls_today: 0, // Will be populated by analytics API
active_sessions: 0, // Will be populated by sessions API
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);
}
};
// Filter users based on type and search
useEffect(() => {
let filtered = users;
// Filter by user type
if (typeFilter !== 'all') {
filtered = filtered.filter(u => u.user_type === typeFilter);
}
// Filter by search query
if (searchQuery) {
filtered = filtered.filter(u =>
u.full_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
u.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
u.tenant_name?.toLowerCase().includes(searchQuery.toLowerCase())
);
}
setFilteredUsers(filtered);
}, [typeFilter, searchQuery, users]);
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 'gt_admin':
return <Badge className="bg-purple-600"><ShieldCheck className="h-3 w-3 mr-1" />GT 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: users.length },
{ id: 'gt_admin', label: 'GT Admins', count: users.filter(u => u.user_type === 'gt_admin').length },
{ id: 'tenant_admin', label: 'Tenant Admins', count: users.filter(u => u.user_type === 'tenant_admin').length },
{ id: 'tenant_user', label: 'Tenant Users', count: users.filter(u => u.user_type === 'tenant_user').length },
];
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">User Management</h1>
<p className="text-muted-foreground">
Manage users, capabilities, and access groups across all tenants
</p>
</div>
<div className="flex space-x-2">
<Button variant="secondary">
<Shield className="h-4 w-4 mr-2" />
Access Groups
</Button>
<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-4 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{users.length}</div>
<p className="text-xs text-muted-foreground">
{users.filter(u => u.status === 'active').length} active
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Active Sessions</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{users.reduce((sum, u) => sum + (u.active_sessions || 0), 0)}
</div>
<p className="text-xs text-muted-foreground">
Currently online
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">API Usage</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{(users.reduce((sum, u) => sum + (u.api_calls_today || 0), 0) / 1000).toFixed(1)}K
</div>
<p className="text-xs text-muted-foreground">
Calls today
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Access Groups</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{Array.from(new Set(users.flatMap(u => u.access_groups))).length}
</div>
<p className="text-xs text-muted-foreground">
Unique groups
</p>
</CardContent>
</Card>
</div>
{/* Type Tabs */}
<div className="flex space-x-2 border-b">
{typeTabs.map(tab => (
<button
key={tab.id}
onClick={() => setTypeFilter(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..."
value={searchQuery}
onChange={(e) => setSearchQuery((e as React.ChangeEvent<HTMLInputElement>).target.value)}
className="pl-10"
/>
</div>
<Button variant="secondary">
<Filter className="h-4 w-4 mr-2" />
Filters
</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 space-x-2">
<Button variant="secondary" size="sm">
<Key className="h-4 w-4 mr-2" />
Reset Passwords
</Button>
<Button variant="secondary" size="sm" className="text-destructive">
<Lock className="h-4 w-4 mr-2" />
Suspend
</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) {
setSelectedUsers(new Set(filteredUsers.map(u => u.id)));
} else {
setSelectedUsers(new Set());
}
}}
checked={selectedUsers.size === filteredUsers.length && filteredUsers.length > 0}
/>
</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">Activity</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">
<div className="space-y-1 text-sm">
{user.last_login && (
<div className="flex items-center space-x-1">
<Clock className="h-3 w-3 text-muted-foreground" />
<span>{new Date(user.last_login).toLocaleTimeString()}</span>
</div>
)}
{user.api_calls_today !== undefined && (
<div className="flex items-center space-x-1">
<Activity className="h-3 w-3 text-muted-foreground" />
<span>{user.api_calls_today} calls</span>
</div>
)}
{user.active_sessions !== undefined && user.active_sessions > 0 && (
<Badge variant="secondary" className="text-xs">
{user.active_sessions} session{user.active_sessions > 1 ? 's' : ''}
</Badge>
)}
</div>
</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>
)}
{/* 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>
);
}