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:
146
apps/control-panel-frontend/src/app/auth/login/page.tsx
Normal file
146
apps/control-panel-frontend/src/app/auth/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
293
apps/control-panel-frontend/src/app/auth/verify-tfa/page.tsx
Normal file
293
apps/control-panel-frontend/src/app/auth/verify-tfa/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
412
apps/control-panel-frontend/src/app/dashboard/api-keys/page.tsx
Normal file
412
apps/control-panel-frontend/src/app/dashboard/api-keys/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
32
apps/control-panel-frontend/src/app/dashboard/layout.tsx
Normal file
32
apps/control-panel-frontend/src/app/dashboard/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
308
apps/control-panel-frontend/src/app/dashboard/models/page.tsx
Normal file
308
apps/control-panel-frontend/src/app/dashboard/models/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
14
apps/control-panel-frontend/src/app/dashboard/page.tsx
Normal file
14
apps/control-panel-frontend/src/app/dashboard/page.tsx
Normal 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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageLoading } from '@/components/ui/loading';
|
||||
|
||||
export default function Loading() {
|
||||
return <PageLoading text="Loading agent library..." />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageLoading } from '@/components/ui/loading';
|
||||
|
||||
export default function Loading() {
|
||||
return <PageLoading text="Loading AI resources..." />;
|
||||
}
|
||||
660
apps/control-panel-frontend/src/app/dashboard/resources/page.tsx
Normal file
660
apps/control-panel-frontend/src/app/dashboard/resources/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
397
apps/control-panel-frontend/src/app/dashboard/security/page.tsx
Normal file
397
apps/control-panel-frontend/src/app/dashboard/security/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
400
apps/control-panel-frontend/src/app/dashboard/system/page.tsx
Normal file
400
apps/control-panel-frontend/src/app/dashboard/system/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
209
apps/control-panel-frontend/src/app/dashboard/templates/page.tsx
Normal file
209
apps/control-panel-frontend/src/app/dashboard/templates/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
283
apps/control-panel-frontend/src/app/dashboard/tenants/page.tsx
Normal file
283
apps/control-panel-frontend/src/app/dashboard/tenants/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
646
apps/control-panel-frontend/src/app/dashboard/users/page.tsx
Normal file
646
apps/control-panel-frontend/src/app/dashboard/users/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
apps/control-panel-frontend/src/app/globals.css
Normal file
130
apps/control-panel-frontend/src/app/globals.css
Normal 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;
|
||||
}
|
||||
9
apps/control-panel-frontend/src/app/health/route.ts
Normal file
9
apps/control-panel-frontend/src/app/health/route.ts
Normal 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()
|
||||
});
|
||||
}
|
||||
56
apps/control-panel-frontend/src/app/layout.tsx
Normal file
56
apps/control-panel-frontend/src/app/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
apps/control-panel-frontend/src/app/page.tsx
Normal file
39
apps/control-panel-frontend/src/app/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
498
apps/control-panel-frontend/src/app/resources/page.tsx
Normal file
498
apps/control-panel-frontend/src/app/resources/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
498
apps/control-panel-frontend/src/app/tenants/page.tsx
Normal file
498
apps/control-panel-frontend/src/app/tenants/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
463
apps/control-panel-frontend/src/app/users/page.tsx
Normal file
463
apps/control-panel-frontend/src/app/users/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user