GT AI OS Community v2.0.33 - Add NVIDIA NIM and Nemotron agents

- Updated python_coding_microproject.csv to use NVIDIA NIM Kimi K2
- Updated kali_linux_shell_simulator.csv to use NVIDIA NIM Kimi K2
  - Made more general-purpose (flexible targets, expanded tools)
- Added nemotron-mini-agent.csv for fast local inference via Ollama
- Added nemotron-agent.csv for advanced reasoning via Ollama
- Added wiki page: Projects for NVIDIA NIMs and Nemotron
This commit is contained in:
HackWeasel
2025-12-12 17:47:14 -05:00
commit 310491a557
750 changed files with 232701 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
{
"extends": ["next/core-web-vitals"]
}

View File

@@ -0,0 +1,62 @@
# Control Panel Frontend Dockerfile
FROM node:18-alpine AS builder
WORKDIR /app
# Accept build args for Docker internal URLs
ARG INTERNAL_API_URL
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_WS_URL
# Set as env vars so next.config.js can use them during build
ENV INTERNAL_API_URL=$INTERNAL_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_WS_URL=$NEXT_PUBLIC_WS_URL
# Copy package files
COPY package*.json ./
# Install dependencies (including devDependencies needed for build)
RUN npm install
# Copy application code
COPY . .
# Set NODE_ENV to production AFTER install, BEFORE build
# This enables Next.js production optimizations without breaking npm install
ENV NODE_ENV=production
# Build the application (next.config.js will use env vars above)
RUN npm run build
# Production stage
FROM node:18-alpine
WORKDIR /app
# Set environment to production
ENV NODE_ENV=production
ENV PORT=3000
# Copy built application
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/next.config.js ./
# Copy public directory if it exists
RUN mkdir -p ./public
# Install production dependencies only
RUN npm install --only=production
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \
adduser -S nextjs -u 1001 && \
chown -R nextjs:nodejs /app
USER nextjs
# Expose port
EXPOSE 3000
# Run the application with npm start (uses PORT env var)
CMD ["npm", "start"]

View File

@@ -0,0 +1,35 @@
# Development Dockerfile for Control Panel Frontend
# This is separate from production Dockerfile
FROM node:18-alpine
WORKDIR /app
# Install dependencies for building native modules
RUN apk add --no-cache python3 make g++ git
# Copy package files from the app
COPY package.json ./
# Remove problematic Radix UI packages temporarily
RUN sed -i '/"@radix-ui\/react-badge":/d; /"@radix-ui\/react-button":/d; /"@radix-ui\/react-card":/d; /"@radix-ui\/react-form":/d; /"@radix-ui\/react-input":/d; /"@radix-ui\/react-table":/d' package.json
# Remove workspace dependencies temporarily for install
RUN sed -i '/"@gt2\/types":/d; /"@gt2\/utils":/d' package.json
# Install dependencies (using npm install since we don't have lock files)
RUN npm install
# Copy application code
COPY . .
# Create minimal workspace packages
RUN mkdir -p node_modules/@gt2/types node_modules/@gt2/utils
RUN echo "export const GT2_VERSION = '1.0.0-dev';" > node_modules/@gt2/types/index.js
RUN echo "export const formatDate = (d) => new Date(d).toLocaleDateString();" > node_modules/@gt2/utils/index.js
# Expose port
EXPOSE 3000
# Development command (will be overridden by docker-compose)
CMD ["npm", "run", "dev"]

View File

@@ -0,0 +1,57 @@
# Multi-stage production build for Control Panel Frontend
# Stage 1: Builder
FROM node:18-alpine AS builder
WORKDIR /app
# Install build dependencies
RUN apk add --no-cache python3 make g++ git
# Copy package files
COPY package.json ./
# Remove problematic dependencies (same as dev)
RUN sed -i '/"@radix-ui\/react-badge":/d; /"@radix-ui\/react-button":/d; /"@radix-ui\/react-card":/d; /"@radix-ui\/react-form":/d; /"@radix-ui\/react-input":/d; /"@radix-ui\/react-table":/d' package.json
RUN sed -i '/"@gt2\/types":/d; /"@gt2\/utils":/d' package.json
# Install dependencies
RUN npm install
# Copy source code
COPY . .
# Create mock packages
RUN mkdir -p node_modules/@gt2/types node_modules/@gt2/utils
RUN echo "export const GT2_VERSION = '1.0.0-dev';" > node_modules/@gt2/types/index.js
RUN echo "export const formatDate = (d) => new Date(d).toLocaleDateString();" > node_modules/@gt2/utils/index.js
# Build for production (this applies compiler.removeConsole)
ENV NODE_ENV=production
RUN npm run build
# Stage 2: Production Runner
FROM node:18-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy necessary files from builder
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
# Set correct permissions
RUN chown -R nextjs:nodejs /app
USER nextjs
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
CMD ["node", "server.js"]

View File

@@ -0,0 +1,45 @@
const nextJest = require('next/jest')
const createJestConfig = nextJest({
// Provide the path to your Next.js app to load next.config.js and .env files
dir: './',
})
// Add any custom config to be passed to Jest
const customJestConfig = {
setupFilesAfterEnv: ['<rootDir>/jest.setup.js'],
moduleNameMapping: {
// Handle module aliases (this will be automatically configured for you based on your tsconfig.json paths)
'^@/(.*)$': '<rootDir>/src/$1',
},
testEnvironment: 'jest-environment-jsdom',
collectCoverageFrom: [
'src/**/*.{js,jsx,ts,tsx}',
'!src/**/*.d.ts',
'!src/app/layout.tsx',
'!src/app/globals.css',
'!src/**/*.stories.{js,jsx,ts,tsx}',
],
coverageThreshold: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
},
testMatch: [
'<rootDir>/src/**/__tests__/**/*.{js,jsx,ts,tsx}',
'<rootDir>/src/**/*.{test,spec}.{js,jsx,ts,tsx}',
],
transform: {
'^.+\\.(js|jsx|ts|tsx)$': ['babel-jest', { presets: ['next/babel'] }],
},
transformIgnorePatterns: [
'/node_modules/',
'^.+\\.module\\.(css|sass|scss)$',
],
}
// createJestConfig is exported this way to ensure that next/jest can load the Next.js config which is async
module.exports = createJestConfig(customJestConfig)

View File

@@ -0,0 +1,117 @@
// Optional: configure or set up a testing framework before each test.
// If you delete this file, remove `setupFilesAfterEnv` from `jest.config.js`
// Used for __tests__/testing-library.js
// Learn more: https://github.com/testing-library/jest-dom
import '@testing-library/jest-dom'
// Mock next/router
jest.mock('next/router', () => ({
useRouter() {
return {
route: '/',
pathname: '/',
query: '',
asPath: '',
push: jest.fn(),
pop: jest.fn(),
reload: jest.fn(),
back: jest.fn(),
prefetch: jest.fn().mockResolvedValue(undefined),
beforePopState: jest.fn(),
events: {
on: jest.fn(),
off: jest.fn(),
emit: jest.fn(),
},
isFallback: false,
}
},
}))
// Mock next/navigation
jest.mock('next/navigation', () => ({
useRouter() {
return {
push: jest.fn(),
replace: jest.fn(),
prefetch: jest.fn(),
back: jest.fn(),
forward: jest.fn(),
refresh: jest.fn(),
}
},
useSearchParams() {
return new URLSearchParams()
},
usePathname() {
return '/'
},
}))
// Mock IntersectionObserver
global.IntersectionObserver = class IntersectionObserver {
constructor() {}
disconnect() {}
observe() {}
unobserve() {}
}
// Mock ResizeObserver
global.ResizeObserver = class ResizeObserver {
constructor() {}
disconnect() {}
observe() {}
unobserve() {}
}
// Mock matchMedia
Object.defineProperty(window, 'matchMedia', {
writable: true,
value: jest.fn().mockImplementation(query => ({
matches: false,
media: query,
onchange: null,
addListener: jest.fn(), // deprecated
removeListener: jest.fn(), // deprecated
addEventListener: jest.fn(),
removeEventListener: jest.fn(),
dispatchEvent: jest.fn(),
})),
})
// Mock localStorage
const localStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn(),
}
global.localStorage = localStorageMock
// Mock sessionStorage
const sessionStorageMock = {
getItem: jest.fn(),
setItem: jest.fn(),
removeItem: jest.fn(),
clear: jest.fn(),
}
global.sessionStorage = sessionStorageMock
// Suppress console errors in tests unless needed
const originalError = console.error
beforeAll(() => {
console.error = (...args) => {
if (
typeof args[0] === 'string' &&
args[0].includes('Warning: ReactDOM.render is no longer supported')
) {
return
}
originalError.call(console, ...args)
}
})
afterAll(() => {
console.error = originalError
})

View File

@@ -0,0 +1,60 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
typescript: {
// Skip type checking during build (types are checked in dev)
ignoreBuildErrors: true
},
eslint: {
// Skip ESLint during build
ignoreDuringBuilds: true
},
compiler: {
// Strip console.* calls in production builds (keep console.error for debugging)
removeConsole: process.env.NODE_ENV === 'production' ? {
exclude: ['error']
} : false
},
// NOTE: Server-side environment variables (INTERNAL_API_URL, etc.) are NOT defined here
// to prevent Next.js from inlining them at build time. They are read from process.env at
// runtime, allowing Docker containers to inject the correct URLs via environment variables.
// This enables flexible deployment without rebuilding when backend URLs change.
async rewrites() {
// Use INTERNAL_API_URL for server-side requests (Docker networking)
// Fall back to Docker hostname - this is evaluated at build time so localhost won't work
const apiUrl = process.env.INTERNAL_API_URL || 'http://control-panel-backend:8000';
return [
{
source: '/api/v1/models',
destination: `${apiUrl}/api/v1/models/`,
},
{
source: '/api/v1/models/:path*',
destination: `${apiUrl}/api/v1/models/:path*`,
},
{
source: '/api/v1/tenants',
destination: `${apiUrl}/api/v1/tenants/`,
},
{
source: '/api/v1/users',
destination: `${apiUrl}/api/v1/users/`,
},
{
source: '/api/v1/resources',
destination: `${apiUrl}/api/v1/resources/`,
},
{
source: '/api/v1/system/:path*',
destination: `${apiUrl}/api/v1/system/:path*`,
},
{
source: '/api/:path*',
destination: `${apiUrl}/api/:path*`,
},
];
},
};
module.exports = nextConfig;

10337
apps/control-panel-frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,70 @@
{
"name": "gt2-control-panel-frontend",
"version": "2.0.30",
"description": "GT 2.0 Control Panel Frontend",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"typecheck": "tsc --noEmit",
"test": "jest --maxWorkers=2 --forceExit --detectOpenHandles",
"test:watch": "jest --watch --maxWorkers=1",
"test:coverage": "jest --coverage --maxWorkers=2 --forceExit"
},
"dependencies": {
"@hookform/resolvers": "^3.3.2",
"@radix-ui/react-alert-dialog": "^1.0.5",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-icons": "^1.3.0",
"@radix-ui/react-label": "^2.0.2",
"@radix-ui/react-navigation-menu": "^1.1.4",
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.5",
"@tanstack/react-query": "^5.90.10",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.0.0",
"date-fns": "^2.30.0",
"dotenv": "^17.2.1",
"lucide-react": "^0.294.0",
"next": "^14.2.34",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.48.2",
"react-hot-toast": "^2.4.1",
"recharts": "^2.8.0",
"tailwind-merge": "^2.1.0",
"tailwindcss-animate": "^1.0.7",
"zod": "^3.22.4",
"zustand": "^4.5.7"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.1.5",
"@testing-library/react": "^14.1.2",
"@testing-library/user-event": "^14.5.1",
"@types/node": "20.19.25",
"@types/react": "^18.2.45",
"@types/react-dom": "^18.2.18",
"autoprefixer": "^10.4.16",
"eslint": "^8.57.0",
"eslint-config-next": "^14.2.34",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"msw": "^2.0.11",
"postcss": "^8.4.32",
"tailwindcss": "^3.3.6",
"typescript": "5.9.3"
},
"overrides": {
"glob": "^11.1.0"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,463 @@
'use client';
import { useState, useEffect } from 'react';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { usersApi } from '@/lib/api';
import toast from 'react-hot-toast';
import AddUserDialog from '@/components/users/AddUserDialog';
import EditUserDialog from '@/components/users/EditUserDialog';
import DeleteUserDialog from '@/components/users/DeleteUserDialog';
import BulkUploadDialog from '@/components/users/BulkUploadDialog';
import {
Plus,
Search,
Filter,
Users,
User,
Shield,
Key,
Building2,
Activity,
CheckCircle,
XCircle,
AlertTriangle,
Settings,
Eye,
Mail,
Calendar,
Clock,
MoreVertical,
UserCog,
ShieldCheck,
Lock,
Edit,
Trash2,
Upload,
} from 'lucide-react';
interface UserType {
id: number;
email: string;
full_name: string;
user_type: 'gt_admin' | 'tenant_admin' | 'tenant_user';
tenant_id?: number;
tenant_name?: string;
status: 'active' | 'inactive' | 'suspended';
capabilities: string[];
access_groups: string[];
last_login?: string;
created_at: string;
api_calls_today?: number;
active_sessions?: number;
}
export default function UsersPage() {
const [users, setUsers] = useState<UserType[]>([]);
const [filteredUsers, setFilteredUsers] = useState<UserType[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [typeFilter, setTypeFilter] = useState('all');
const [selectedUsers, setSelectedUsers] = useState<Set<number>>(new Set());
// Dialog states
const [addDialogOpen, setAddDialogOpen] = useState(false);
const [editDialogOpen, setEditDialogOpen] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [bulkUploadDialogOpen, setBulkUploadDialogOpen] = useState(false);
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
const [userToDelete, setUserToDelete] = useState<{
id: number;
email: string;
full_name: string;
user_type: string;
} | null>(null);
// Fetch real users from API - GT 2.0 "No Mocks" principle
useEffect(() => {
fetchUsers();
}, []);
const fetchUsers = async () => {
try {
setLoading(true);
const response = await usersApi.list(1, 100);
const userData = response.data?.users || response.data?.data || [];
// Map API response to expected format
const mappedUsers: UserType[] = userData.map((user: any) => ({
...user,
status: user.is_active ? 'active' : 'suspended',
api_calls_today: 0, // Will be populated by analytics API
active_sessions: 0, // Will be populated by sessions API
capabilities: user.capabilities || [],
access_groups: user.access_groups || [],
}));
setUsers(mappedUsers);
setFilteredUsers(mappedUsers);
} catch (error) {
console.error('Failed to fetch users:', error);
toast.error('Failed to load users');
setUsers([]);
setFilteredUsers([]);
} finally {
setLoading(false);
}
};
// Filter users based on type and search
useEffect(() => {
let filtered = users;
// Filter by user type
if (typeFilter !== 'all') {
filtered = filtered.filter(u => u.user_type === typeFilter);
}
// Filter by search query
if (searchQuery) {
filtered = filtered.filter(u =>
u.full_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
u.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
u.tenant_name?.toLowerCase().includes(searchQuery.toLowerCase())
);
}
setFilteredUsers(filtered);
}, [typeFilter, searchQuery, users]);
const getStatusBadge = (status: string) => {
switch (status) {
case 'active':
return <Badge variant="default" className="bg-green-600"><CheckCircle className="h-3 w-3 mr-1" />Active</Badge>;
case 'inactive':
return <Badge variant="secondary"><Clock className="h-3 w-3 mr-1" />Inactive</Badge>;
case 'suspended':
return <Badge variant="destructive"><XCircle className="h-3 w-3 mr-1" />Suspended</Badge>;
default:
return <Badge variant="secondary">{status}</Badge>;
}
};
const getUserTypeBadge = (type: string) => {
switch (type) {
case 'gt_admin':
return <Badge className="bg-purple-600"><ShieldCheck className="h-3 w-3 mr-1" />GT Admin</Badge>;
case 'tenant_admin':
return <Badge className="bg-blue-600"><UserCog className="h-3 w-3 mr-1" />Tenant Admin</Badge>;
case 'tenant_user':
return <Badge variant="secondary"><User className="h-3 w-3 mr-1" />User</Badge>;
default:
return <Badge variant="secondary">{type}</Badge>;
}
};
const typeTabs = [
{ id: 'all', label: 'All Users', count: users.length },
{ id: 'gt_admin', label: 'GT Admins', count: users.filter(u => u.user_type === 'gt_admin').length },
{ id: 'tenant_admin', label: 'Tenant Admins', count: users.filter(u => u.user_type === 'tenant_admin').length },
{ id: 'tenant_user', label: 'Tenant Users', count: users.filter(u => u.user_type === 'tenant_user').length },
];
return (
<div className="space-y-6">
<div className="flex justify-between items-center">
<div>
<h1 className="text-3xl font-bold">User Management</h1>
<p className="text-muted-foreground">
Manage users, capabilities, and access groups across all tenants
</p>
</div>
<div className="flex space-x-2">
<Button variant="secondary">
<Shield className="h-4 w-4 mr-2" />
Access Groups
</Button>
<Button variant="secondary" onClick={() => setBulkUploadDialogOpen(true)}>
<Upload className="h-4 w-4 mr-2" />
Bulk Upload
</Button>
<Button onClick={() => setAddDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Add User
</Button>
</div>
</div>
{/* Quick Stats */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{users.length}</div>
<p className="text-xs text-muted-foreground">
{users.filter(u => u.status === 'active').length} active
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Active Sessions</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{users.reduce((sum, u) => sum + (u.active_sessions || 0), 0)}
</div>
<p className="text-xs text-muted-foreground">
Currently online
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">API Usage</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{(users.reduce((sum, u) => sum + (u.api_calls_today || 0), 0) / 1000).toFixed(1)}K
</div>
<p className="text-xs text-muted-foreground">
Calls today
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium">Access Groups</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{Array.from(new Set(users.flatMap(u => u.access_groups))).length}
</div>
<p className="text-xs text-muted-foreground">
Unique groups
</p>
</CardContent>
</Card>
</div>
{/* Type Tabs */}
<div className="flex space-x-2 border-b">
{typeTabs.map(tab => (
<button
key={tab.id}
onClick={() => setTypeFilter(tab.id)}
className={`px-4 py-2 border-b-2 transition-colors ${
typeFilter === tab.id
? 'border-primary text-primary'
: 'border-transparent text-muted-foreground hover:text-foreground'
}`}
>
<span>{tab.label}</span>
<Badge variant="secondary" className="ml-2">{tab.count}</Badge>
</button>
))}
</div>
{/* Search and Filters */}
<div className="flex space-x-4">
<div className="flex-1 relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Search users by name, email, or tenant..."
value={searchQuery}
onChange={(e) => setSearchQuery((e as React.ChangeEvent<HTMLInputElement>).target.value)}
className="pl-10"
/>
</div>
<Button variant="secondary">
<Filter className="h-4 w-4 mr-2" />
Filters
</Button>
</div>
{/* Bulk Actions */}
{selectedUsers.size > 0 && (
<Card className="bg-muted/50">
<CardContent className="flex items-center justify-between py-3">
<span className="text-sm">
{selectedUsers.size} user{selectedUsers.size > 1 ? 's' : ''} selected
</span>
<div className="flex space-x-2">
<Button variant="secondary" size="sm">
<Key className="h-4 w-4 mr-2" />
Reset Passwords
</Button>
<Button variant="secondary" size="sm" className="text-destructive">
<Lock className="h-4 w-4 mr-2" />
Suspend
</Button>
</div>
</CardContent>
</Card>
)}
{/* Users Table */}
{loading ? (
<div className="flex items-center justify-center h-64">
<Activity className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
) : (
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full">
<thead className="border-b bg-muted/50">
<tr>
<th className="p-4 text-left">
<input
type="checkbox"
onChange={(e) => {
if (e.target.checked) {
setSelectedUsers(new Set(filteredUsers.map(u => u.id)));
} else {
setSelectedUsers(new Set());
}
}}
checked={selectedUsers.size === filteredUsers.length && filteredUsers.length > 0}
/>
</th>
<th className="p-4 text-left font-medium">User</th>
<th className="p-4 text-left font-medium">Type</th>
<th className="p-4 text-left font-medium">Tenant</th>
<th className="p-4 text-left font-medium">Status</th>
<th className="p-4 text-left font-medium">Activity</th>
<th className="p-4 text-left font-medium">Actions</th>
</tr>
</thead>
<tbody>
{filteredUsers.map(user => (
<tr key={user.id} className="border-b hover:bg-muted/30">
<td className="p-4">
<input
type="checkbox"
checked={selectedUsers.has(user.id)}
onChange={(e) => {
const newSelected = new Set(selectedUsers);
if (e.target.checked) {
newSelected.add(user.id);
} else {
newSelected.delete(user.id);
}
setSelectedUsers(newSelected);
}}
/>
</td>
<td className="p-4">
<div>
<div className="font-medium">{user.full_name}</div>
<div className="text-sm text-muted-foreground flex items-center space-x-1">
<Mail className="h-3 w-3" />
<span>{user.email}</span>
</div>
</div>
</td>
<td className="p-4">
{getUserTypeBadge(user.user_type)}
</td>
<td className="p-4">
{user.tenant_name ? (
<div className="flex items-center space-x-1">
<Building2 className="h-4 w-4 text-muted-foreground" />
<span>{user.tenant_name}</span>
</div>
) : (
<span className="text-muted-foreground">System</span>
)}
</td>
<td className="p-4">
{getStatusBadge(user.status)}
</td>
<td className="p-4">
<div className="space-y-1 text-sm">
{user.last_login && (
<div className="flex items-center space-x-1">
<Clock className="h-3 w-3 text-muted-foreground" />
<span>{new Date(user.last_login).toLocaleTimeString()}</span>
</div>
)}
{user.api_calls_today !== undefined && (
<div className="flex items-center space-x-1">
<Activity className="h-3 w-3 text-muted-foreground" />
<span>{user.api_calls_today} calls</span>
</div>
)}
{user.active_sessions !== undefined && user.active_sessions > 0 && (
<Badge variant="secondary" className="text-xs">
{user.active_sessions} session{user.active_sessions > 1 ? 's' : ''}
</Badge>
)}
</div>
</td>
<td className="p-4">
<div className="flex space-x-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
setSelectedUserId(user.id);
setEditDialogOpen(true);
}}
>
<Edit className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
setUserToDelete({
id: user.id,
email: user.email,
full_name: user.full_name,
user_type: user.user_type,
});
setDeleteDialogOpen(true);
}}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
{/* Dialogs */}
<AddUserDialog
open={addDialogOpen}
onOpenChange={setAddDialogOpen}
onUserAdded={fetchUsers}
/>
<EditUserDialog
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
userId={selectedUserId}
onUserUpdated={fetchUsers}
/>
<DeleteUserDialog
open={deleteDialogOpen}
onOpenChange={setDeleteDialogOpen}
user={userToDelete}
onUserDeleted={fetchUsers}
/>
<BulkUploadDialog
open={bulkUploadDialogOpen}
onOpenChange={setBulkUploadDialogOpen}
onUploadComplete={fetchUsers}
/>
</div>
);
}

View File

@@ -0,0 +1,260 @@
/**
* Unit tests for login page component
*/
import React from 'react'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { useRouter } from 'next/navigation'
import LoginPage from '../../app/auth/login/page'
import { useAuthStore } from '../../stores/auth-store'
// Mock next/navigation
jest.mock('next/navigation')
const mockUseRouter = useRouter as jest.MockedFunction<typeof useRouter>
// Mock auth store
jest.mock('../../stores/auth-store')
const mockUseAuthStore = useAuthStore as jest.MockedFunction<typeof useAuthStore>
// Mock toast
jest.mock('react-hot-toast', () => ({
success: jest.fn(),
error: jest.fn()
}))
describe('LoginPage', () => {
const mockPush = jest.fn()
const mockReplace = jest.fn()
const mockLogin = jest.fn()
beforeEach(() => {
mockUseRouter.mockReturnValue({
push: mockPush,
replace: mockReplace,
refresh: jest.fn(),
back: jest.fn(),
forward: jest.fn(),
prefetch: jest.fn()
})
mockUseAuthStore.mockReturnValue({
user: null,
token: null,
isLoading: false,
isAuthenticated: false,
login: mockLogin,
logout: jest.fn(),
checkAuth: jest.fn(),
updateUser: jest.fn(),
changePassword: jest.fn()
})
jest.clearAllMocks()
})
test('renders login form correctly', () => {
render(<LoginPage />)
expect(screen.getByText('GT 2.0 Control Panel')).toBeInTheDocument()
expect(screen.getByText('Sign in to your administrator account')).toBeInTheDocument()
expect(screen.getByLabelText(/email/i)).toBeInTheDocument()
expect(screen.getByLabelText(/password/i)).toBeInTheDocument()
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument()
})
test('displays demo credentials', () => {
render(<LoginPage />)
expect(screen.getByText('Demo credentials:')).toBeInTheDocument()
expect(screen.getByText('admin@gt2.dev / admin123')).toBeInTheDocument()
})
test('validates required fields', async () => {
const user = userEvent.setup()
render(<LoginPage />)
const submitButton = screen.getByRole('button', { name: /sign in/i })
await user.click(submitButton)
await waitFor(() => {
expect(screen.getByText(/invalid email address/i)).toBeInTheDocument()
})
})
test('validates email format', async () => {
const user = userEvent.setup()
render(<LoginPage />)
const emailInput = screen.getByLabelText(/email/i)
const submitButton = screen.getByRole('button', { name: /sign in/i })
await user.type(emailInput, 'invalid-email')
await user.click(submitButton)
await waitFor(() => {
expect(screen.getByText(/invalid email address/i)).toBeInTheDocument()
})
})
test('submits form with valid credentials', async () => {
const user = userEvent.setup()
mockLogin.mockResolvedValue(true)
render(<LoginPage />)
const emailInput = screen.getByLabelText(/email/i)
const passwordInput = screen.getByLabelText(/password/i)
const submitButton = screen.getByRole('button', { name: /sign in/i })
await user.type(emailInput, 'admin@gt2.dev')
await user.type(passwordInput, 'admin123')
await user.click(submitButton)
await waitFor(() => {
expect(mockLogin).toHaveBeenCalledWith('admin@gt2.dev', 'admin123')
})
})
test('redirects to dashboard on successful login', async () => {
const user = userEvent.setup()
mockLogin.mockResolvedValue(true)
render(<LoginPage />)
const emailInput = screen.getByLabelText(/email/i)
const passwordInput = screen.getByLabelText(/password/i)
const submitButton = screen.getByRole('button', { name: /sign in/i })
await user.type(emailInput, 'admin@gt2.dev')
await user.type(passwordInput, 'admin123')
await user.click(submitButton)
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/dashboard')
})
})
test('shows loading state during submission', async () => {
const user = userEvent.setup()
mockLogin.mockImplementation(() => new Promise(resolve => setTimeout(() => resolve(true), 100)))
render(<LoginPage />)
const emailInput = screen.getByLabelText(/email/i)
const passwordInput = screen.getByLabelText(/password/i)
const submitButton = screen.getByRole('button', { name: /sign in/i })
await user.type(emailInput, 'admin@gt2.dev')
await user.type(passwordInput, 'admin123')
await user.click(submitButton)
expect(screen.getByText(/signing in/i)).toBeInTheDocument()
expect(submitButton).toBeDisabled()
})
test('handles failed login attempt', async () => {
const user = userEvent.setup()
mockLogin.mockResolvedValue(false)
render(<LoginPage />)
const emailInput = screen.getByLabelText(/email/i)
const passwordInput = screen.getByLabelText(/password/i)
const submitButton = screen.getByRole('button', { name: /sign in/i })
await user.type(emailInput, 'admin@gt2.dev')
await user.type(passwordInput, 'wrongpassword')
await user.click(submitButton)
await waitFor(() => {
expect(mockLogin).toHaveBeenCalledWith('admin@gt2.dev', 'wrongpassword')
})
expect(mockPush).not.toHaveBeenCalled()
})
test('toggles password visibility', async () => {
const user = userEvent.setup()
render(<LoginPage />)
const passwordInput = screen.getByLabelText(/password/i) as HTMLInputElement
const toggleButton = screen.getByRole('button', { name: '' }) // Password toggle button
expect(passwordInput.type).toBe('password')
await user.click(toggleButton)
expect(passwordInput.type).toBe('text')
await user.click(toggleButton)
expect(passwordInput.type).toBe('password')
})
test('redirects if already authenticated', () => {
mockUseAuthStore.mockReturnValue({
user: { id: 1, email: 'test@example.com', full_name: 'Test User', user_type: 'super_admin' },
token: 'mock-token',
isLoading: false,
isAuthenticated: true,
login: mockLogin,
logout: jest.fn(),
checkAuth: jest.fn(),
updateUser: jest.fn(),
changePassword: jest.fn()
})
render(<LoginPage />)
expect(mockReplace).toHaveBeenCalledWith('/dashboard')
})
test('shows loading store state', () => {
mockUseAuthStore.mockReturnValue({
user: null,
token: null,
isLoading: true,
isAuthenticated: false,
login: mockLogin,
logout: jest.fn(),
checkAuth: jest.fn(),
updateUser: jest.fn(),
changePassword: jest.fn()
})
render(<LoginPage />)
const submitButton = screen.getByRole('button', { name: /signing in/i })
expect(submitButton).toBeDisabled()
})
test('keyboard navigation works correctly', async () => {
const user = userEvent.setup()
render(<LoginPage />)
const emailInput = screen.getByLabelText(/email/i)
const passwordInput = screen.getByLabelText(/password/i)
await user.click(emailInput)
await user.keyboard('{Tab}')
expect(passwordInput).toHaveFocus()
})
test('form submission on Enter key', async () => {
const user = userEvent.setup()
mockLogin.mockResolvedValue(true)
render(<LoginPage />)
const emailInput = screen.getByLabelText(/email/i)
const passwordInput = screen.getByLabelText(/password/i)
await user.type(emailInput, 'admin@gt2.dev')
await user.type(passwordInput, 'admin123')
await user.keyboard('{Enter}')
await waitFor(() => {
expect(mockLogin).toHaveBeenCalledWith('admin@gt2.dev', 'admin123')
})
})
})

View File

@@ -0,0 +1,304 @@
"use client";
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useToast } from '@/components/ui/use-toast';
import { apiKeysApi } from '@/lib/api';
import {
Key,
Eye,
EyeOff,
TestTube,
Loader2,
CheckCircle,
AlertCircle,
} from 'lucide-react';
// Provider configuration type
export interface ProviderConfig {
id: string;
name: string;
description: string;
keyPrefix: string;
consoleUrl: string;
consoleName: string;
}
interface AddApiKeyDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
tenantId: number;
tenantName: string;
existingKey?: boolean;
onKeyAdded?: () => void;
provider: ProviderConfig;
}
export default function AddApiKeyDialog({
open,
onOpenChange,
tenantId,
tenantName,
existingKey = false,
onKeyAdded,
provider,
}: AddApiKeyDialogProps) {
const [apiKey, setApiKey] = useState('');
const [showKey, setShowKey] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const [testResult, setTestResult] = useState<{
success: boolean;
message: string;
} | null>(null);
const { toast } = useToast();
// Validate key format based on provider
const isValidFormat = apiKey.startsWith(provider.keyPrefix) && apiKey.length > 10;
const handleTest = async () => {
if (!isValidFormat) {
toast({
title: "Invalid Format",
description: `${provider.name} API keys must start with '${provider.keyPrefix}'`,
variant: "destructive",
});
return;
}
setIsTesting(true);
setTestResult(null);
try {
// First save the key temporarily to test it
await apiKeysApi.setKey({
tenant_id: tenantId,
provider: provider.id,
api_key: apiKey,
enabled: true,
});
// Then test it
const response = await apiKeysApi.testKey(tenantId, provider.id);
const result = response.data;
setTestResult({
success: result.valid,
message: result.message,
});
if (result.valid) {
toast({
title: "Connection Successful",
description: `The ${provider.name} API key is valid and working`,
});
} else {
toast({
title: "Connection Failed",
description: result.message || "The API key could not be validated",
variant: "destructive",
});
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Connection test failed';
setTestResult({
success: false,
message: errorMessage,
});
toast({
title: "Test Failed",
description: errorMessage,
variant: "destructive",
});
} finally {
setIsTesting(false);
}
};
const handleSubmit = async () => {
if (!isValidFormat) {
toast({
title: "Invalid Format",
description: `${provider.name} API keys must start with '${provider.keyPrefix}'`,
variant: "destructive",
});
return;
}
setIsSaving(true);
try {
await apiKeysApi.setKey({
tenant_id: tenantId,
provider: provider.id,
api_key: apiKey,
enabled: true,
});
toast({
title: existingKey ? "API Key Updated" : "API Key Added",
description: `${provider.name} API key has been ${existingKey ? 'updated' : 'configured'} for ${tenantName}`,
});
// Reset form
setApiKey('');
setTestResult(null);
onOpenChange(false);
// Notify parent
if (onKeyAdded) {
onKeyAdded();
}
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : 'Failed to save API key';
toast({
title: "Save Failed",
description: errorMessage,
variant: "destructive",
});
} finally {
setIsSaving(false);
}
};
const handleClose = () => {
setApiKey('');
setTestResult(null);
setShowKey(false);
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Key className="w-5 h-5" />
{existingKey ? 'Update' : 'Add'} {provider.name} API Key
</DialogTitle>
<DialogDescription>
Configure the {provider.name} API key for <strong>{tenantName}</strong>.
This key will be encrypted and stored securely.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="api_key">API Key</Label>
<div className="relative">
<Input
id="api_key"
type={showKey ? 'text' : 'password'}
placeholder={`${provider.keyPrefix}xxxxxxxxxxxxxxxxxxxx`}
value={apiKey}
onChange={(e) => {
setApiKey(e.target.value);
setTestResult(null);
}}
className="pr-10 font-mono"
/>
<button
type="button"
className="absolute inset-y-0 right-0 pr-3 flex items-center"
onClick={() => setShowKey(!showKey)}
>
{showKey ? (
<EyeOff className="h-4 w-4 text-gray-400" />
) : (
<Eye className="h-4 w-4 text-gray-400" />
)}
</button>
</div>
<p className="text-sm text-muted-foreground">
Get your API key from{' '}
<a
href={provider.consoleUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline"
>
{provider.consoleName}
</a>
</p>
{apiKey && !isValidFormat && (
<p className="text-sm text-destructive">
{provider.name} API keys must start with &apos;{provider.keyPrefix}&apos;
</p>
)}
</div>
{/* Test Connection Button */}
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
onClick={handleTest}
disabled={!isValidFormat || isTesting}
className="flex-1"
>
{isTesting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Testing...
</>
) : (
<>
<TestTube className="mr-2 h-4 w-4" />
Test Connection
</>
)}
</Button>
</div>
{/* Test Result */}
{testResult && (
<div
className={`flex items-center gap-2 p-3 rounded-md text-sm ${
testResult.success
? 'bg-green-50 text-green-700 border border-green-200'
: 'bg-red-50 text-red-700 border border-red-200'
}`}
>
{testResult.success ? (
<CheckCircle className="h-4 w-4" />
) : (
<AlertCircle className="h-4 w-4" />
)}
{testResult.message}
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button
onClick={handleSubmit}
disabled={!isValidFormat || isSaving}
>
{isSaving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Saving...
</>
) : (
<>{existingKey ? 'Update' : 'Add'} Key</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,55 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import { useAuthStore } from '@/stores/auth-store';
import { Loader2 } from 'lucide-react';
interface AuthGuardProps {
children: React.ReactNode;
}
export function AuthGuard({ children }: AuthGuardProps) {
const router = useRouter();
const { isAuthenticated, isLoading, token, user, checkAuth } = useAuthStore();
const [isInitialized, setIsInitialized] = useState(false);
useEffect(() => {
const initializeAuth = async () => {
await checkAuth();
setIsInitialized(true);
};
initializeAuth();
}, [checkAuth]);
useEffect(() => {
console.log('AuthGuard state:', { isInitialized, isAuthenticated, isLoading, hasToken: !!token, userType: user?.user_type });
if (isInitialized && !isLoading) {
// Redirect if not authenticated OR not a super_admin
if (!isAuthenticated || (user && user.user_type !== 'super_admin')) {
console.log('Redirecting to login from AuthGuard - not authenticated or not super_admin');
router.replace('/auth/login');
}
}
}, [isInitialized, isAuthenticated, isLoading, router, token, user]);
if (!isInitialized || isLoading) {
return (
<div className="min-h-screen flex items-center justify-center">
<div className="flex items-center space-x-2">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="text-muted-foreground">Loading...</span>
</div>
</div>
);
}
// Block access if not authenticated or not super_admin
if (!isAuthenticated || (user && user.user_type !== 'super_admin')) {
return null; // Will redirect to login
}
return <>{children}</>;
}

View File

@@ -0,0 +1,90 @@
'use client';
import { useAuthStore } from '@/stores/auth-store';
import { Button } from '@/components/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Settings, LogOut } from 'lucide-react';
import { useRouter } from 'next/navigation';
export function DashboardHeader() {
const router = useRouter();
const { user, logout } = useAuthStore();
const handleLogout = () => {
logout();
router.push('/auth/login');
};
const getInitials = (name: string) => {
return name
.split(' ')
.map(word => word[0])
.join('')
.toUpperCase()
.slice(0, 2);
};
return (
<header className="sticky top-0 z-40 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="container flex h-16 items-center justify-between">
<div className="flex items-center space-x-4">
<div className="flex items-center space-x-2">
<div className="w-8 h-8 bg-primary rounded-lg flex items-center justify-center">
<span className="text-sm font-bold text-primary-foreground">GT</span>
</div>
<div>
<h1 className="text-lg font-semibold">GT 2.0</h1>
<p className="text-xs text-muted-foreground">Control Panel</p>
</div>
</div>
</div>
<div className="flex items-center space-x-4">
{/* User menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="relative h-9 w-9 rounded-full">
<Avatar className="h-9 w-9">
<AvatarFallback className="bg-primary text-primary-foreground">
{user ? getInitials(user.full_name) : 'GT'}
</AvatarFallback>
</Avatar>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{user?.full_name}</p>
<p className="text-xs leading-none text-muted-foreground">
{user?.email}
</p>
<p className="text-xs leading-none text-muted-foreground capitalize">
{user?.user_type.replace('_', ' ')}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => router.push('/dashboard/settings')}>
<Settings className="mr-2 h-4 w-4" />
<span>Settings</span>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
<span>Log out</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</header>
);
}

View File

@@ -0,0 +1,82 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { cn } from '@/lib/utils';
import {
Building2,
Users,
Braces,
Settings,
Key,
Cpu
} from 'lucide-react';
const navigation = [
{
name: 'Tenant',
href: '/dashboard/tenants',
icon: Building2,
},
{
name: 'Users',
href: '/dashboard/users',
icon: Users,
},
{
name: 'Models',
href: '/dashboard/models',
icon: Braces,
},
{
name: 'API Keys',
href: '/dashboard/api-keys',
icon: Key,
},
// System menu item - Hidden until update/install process is properly architected
// {
// name: 'System',
// href: '/dashboard/system',
// icon: Cpu,
// },
{
name: 'Settings',
href: '/dashboard/settings',
icon: Settings,
},
];
export function DashboardNav() {
const pathname = usePathname();
return (
<nav className="gt-sidebar w-64 min-h-screen p-4">
<div className="space-y-2">
{navigation.map((item) => {
const isActive = pathname === item.href || pathname.startsWith(item.href);
return (
<Link
key={item.name}
href={item.href}
className={cn(
'gt-nav-item',
isActive ? 'gt-nav-item-active' : 'gt-nav-item-inactive'
)}
>
<item.icon className="h-4 w-4" />
<span>{item.name}</span>
</Link>
);
})}
</div>
{/* Version display */}
<div className="mt-4 text-center">
<p className="text-xs text-muted-foreground">
GT AI OS Community | v2.0.33
</p>
</div>
</nav>
);
}

View File

@@ -0,0 +1,684 @@
"use client";
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { useToast } from '@/components/ui/use-toast';
import {
Cpu,
TestTube,
RefreshCw,
CheckCircle,
AlertCircle,
AlertTriangle,
ExternalLink,
Users,
Info
} from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
interface CustomEndpoint {
provider: string;
name: string;
endpoint: string;
enabled: boolean;
health_status: 'healthy' | 'unhealthy' | 'testing' | 'unknown';
description: string;
is_external: boolean;
requires_api_key: boolean;
last_test?: string;
is_custom?: boolean;
model_type?: 'llm' | 'embedding' | 'both';
}
interface AddModelDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onModelAdded?: () => void;
}
export default function AddModelDialog({ open, onOpenChange, onModelAdded }: AddModelDialogProps) {
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{
success: boolean;
status?: 'healthy' | 'degraded' | 'unhealthy';
message: string;
latency_ms?: number;
error_type?: string;
} | null>(null);
const [customEndpoints, setCustomEndpoints] = useState<CustomEndpoint[]>([]);
const [formData, setFormData] = useState({
model_id: '',
name: '',
provider: '', // No default provider
model_type: '',
endpoint: '', // No default endpoint
description: '',
context_window: '',
max_tokens: '',
dimensions: '', // For embedding models
});
const { toast } = useToast();
// Load custom endpoints from localStorage
useEffect(() => {
const loadCustomEndpoints = () => {
try {
const stored = localStorage.getItem('custom_endpoints');
console.log('Raw stored endpoints from localStorage:', stored);
if (stored) {
const parsed = JSON.parse(stored);
console.log('Parsed custom endpoints:', parsed);
setCustomEndpoints(parsed);
} else {
console.log('No custom endpoints found in localStorage');
}
} catch (error) {
console.error('Failed to load custom endpoints:', error);
}
};
if (open) {
loadCustomEndpoints();
}
}, [open]);
const handleProviderChange = (providerId: string) => {
console.log('Provider changed to:', providerId);
console.log('Available custom endpoints:', customEndpoints);
// Check if this is a custom endpoint
const customEndpoint = customEndpoints.find(ep => ep.provider === providerId);
console.log('Found custom endpoint:', customEndpoint);
if (customEndpoint) {
// Selected a configured endpoint - keep the endpoint ID as provider for Select consistency
console.log('Setting endpoint URL to:', customEndpoint.endpoint);
setFormData(prev => ({
...prev,
provider: providerId, // Use the endpoint ID to maintain Select consistency
endpoint: customEndpoint.endpoint, // Auto-fill the configured URL
model_type: customEndpoint.model_type || prev.model_type
}));
} else {
// Selected a default provider
setFormData(prev => {
const updated = { ...prev, provider: providerId };
// Auto-populate default endpoints
switch (providerId) {
case 'nvidia':
updated.endpoint = 'https://integrate.api.nvidia.com/v1/chat/completions';
break;
case 'groq':
updated.endpoint = 'https://api.groq.com/openai/v1/chat/completions';
break;
case 'ollama-dgx-x86':
updated.endpoint = 'http://ollama-host:11434/v1/chat/completions';
break;
case 'ollama-macos':
updated.endpoint = 'http://host.docker.internal:11434/v1/chat/completions';
break;
case 'local':
updated.endpoint = 'http://localhost:8000/v1/chat/completions';
break;
default:
updated.endpoint = '';
}
return updated;
});
}
};
const handleInputChange = (field: string, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
const handleTestEndpoint = async () => {
if (!formData.endpoint) {
toast({
title: "Missing Endpoint",
description: "Please enter an endpoint URL to test",
variant: "destructive",
});
return;
}
setTesting(true);
setTestResult(null);
try {
// Test endpoint connectivity via backend API
const response = await fetch('/api/v1/models/test-endpoint', {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
endpoint: formData.endpoint,
provider: formData.provider
})
});
const result = await response.json();
// Build message based on status
let message = result.error || "Endpoint is responding correctly";
if (result.status === 'degraded' && !result.error) {
message = "Endpoint responding but with high latency";
}
setTestResult({
success: result.healthy || false,
status: result.status || (result.healthy ? 'healthy' : 'unhealthy'),
message: message,
latency_ms: result.latency_ms,
error_type: result.error_type
});
} catch (error) {
setTestResult({
success: false,
status: 'unhealthy',
message: "Connection test failed",
error_type: 'connection_error'
});
} finally {
setTesting(false);
}
};
const handleSubmit = async () => {
try {
// Prepare submission data - resolve custom endpoint provider if needed
const submissionData: Record<string, any> = { ...formData };
// Check if the provider is actually a custom endpoint ID
const customEndpoint = customEndpoints.find(ep => ep.provider === formData.provider);
if (customEndpoint) {
// Use the actual provider name from the custom endpoint
submissionData.provider = customEndpoint.provider;
}
// Set default status (pricing is now managed on the Billing page)
submissionData.status = {
is_active: true,
is_compound: false
};
// Set default pricing (managed on Billing page)
submissionData.cost_per_million_input = 0;
submissionData.cost_per_million_output = 0;
const apiUrl = '/api/v1/models/';
console.log('Making API request to:', apiUrl);
console.log('Submission data:', submissionData);
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(submissionData)
});
console.log('Response status:', response.status);
console.log('Response headers:', Object.fromEntries(response.headers.entries()));
if (response.ok) {
const result = await response.json();
console.log('Success response:', result);
toast({
title: "Model Added",
description: `Successfully added ${formData.name} to the model registry`,
});
// Reset form and close dialog
setFormData({
model_id: '',
name: '',
provider: '', // No default provider
model_type: '',
endpoint: '', // No default endpoint
description: '',
context_window: '',
max_tokens: '',
dimensions: '',
});
setTestResult(null);
onOpenChange(false);
// Notify parent to refresh data
if (onModelAdded) {
onModelAdded();
}
} else {
const errorData = await response.text();
console.error('API error response:', errorData);
toast({
title: "Failed to Add Model",
description: `Server returned ${response.status}: ${errorData.substring(0, 100)}`,
variant: "destructive",
});
}
} catch (error) {
console.error('Network error:', error);
toast({
title: "Network Error",
description: error instanceof Error ? error.message : "Could not connect to server",
variant: "destructive",
});
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Cpu className="w-5 h-5" />
Add New Model
</DialogTitle>
<DialogDescription>
Add a new AI model to the GT 2.0 registry. This will make it available across all clusters.
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* Basic Information */}
<div className="space-y-4">
<h3 className="text-lg font-medium">Basic Information</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="model_id">Model ID *</Label>
<Input
id="model_id"
value={formData.model_id}
onChange={(e) => handleInputChange('model_id', e.target.value)}
placeholder="llama-3.3-70b-versatile"
/>
</div>
<div>
<Label htmlFor="name">Display Name *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
placeholder="Llama 3.3 70B Versatile"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="provider">Provider *</Label>
<Select value={formData.provider} onValueChange={(value) => handleProviderChange(value)}>
<SelectTrigger>
<SelectValue placeholder="Select provider/endpoint" />
</SelectTrigger>
<SelectContent>
{/* Default providers */}
<SelectItem key="nvidia" value="nvidia">NVIDIA NIM (build.nvidia.com)</SelectItem>
<SelectItem key="ollama-dgx-x86" value="ollama-dgx-x86">Local Ollama (Ubuntu x86 / DGX ARM)</SelectItem>
<SelectItem key="ollama-macos" value="ollama-macos">Local Ollama (macOS Apple Silicon)</SelectItem>
<SelectItem key="groq" value="groq">Groq (api.groq.com)</SelectItem>
<SelectItem key="local" value="local">Custom Endpoint</SelectItem>
{/* Custom configured endpoints */}
{customEndpoints.length > 0 && (
<>
<SelectItem key="separator" value="separator" disabled className="text-xs text-muted-foreground font-medium px-2 py-1">
Configured Endpoints
</SelectItem>
{customEndpoints.map((endpoint) => (
<SelectItem key={endpoint.provider} value={endpoint.provider}>
{endpoint.name} ({endpoint.provider})
{endpoint.model_type && (
<span className="ml-2 text-xs text-muted-foreground">
[{endpoint.model_type}]
</span>
)}
</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="model_type">Model Type *</Label>
<Select value={formData.model_type} onValueChange={(value) => handleInputChange('model_type', value)}>
<SelectTrigger>
<SelectValue placeholder="Select type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="llm">Language Model (LLM)</SelectItem>
<SelectItem value="embedding">Embedding Model</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<Label htmlFor="endpoint">Endpoint URL *</Label>
<div className="flex gap-2">
<Input
id="endpoint"
value={formData.endpoint}
onChange={(e) => handleInputChange('endpoint', e.target.value)}
placeholder={
formData.provider === 'groq'
? "https://api.groq.com/openai/v1/chat/completions"
: "http://localhost:8000/v1/chat/completions"
}
className={formData.provider === 'local' ? "border-green-200 bg-green-50" : "border-purple-200 bg-purple-50"}
/>
<Button
type="button"
variant="outline"
onClick={handleTestEndpoint}
disabled={!formData.endpoint || testing}
>
{testing ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<TestTube className="w-4 h-4" />
)}
</Button>
</div>
{formData.provider === 'groq' && (
<div className="mt-2 p-3 rounded-md bg-orange-50 border border-orange-200">
<p className="text-sm font-medium text-orange-800 mb-2">Groq Setup Steps:</p>
<ol className="text-sm text-orange-700 space-y-2 list-decimal list-inside">
<li>
<a
href="https://console.groq.com"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-orange-900"
>
Create a Groq account
</a>{' '}
and{' '}
<a
href="https://console.groq.com/keys"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-orange-900"
>
generate an API key
</a>
</li>
<li>
Add your API key on the{' '}
<a href="/dashboard/api-keys" className="underline hover:text-orange-900">
API Keys page
</a>
</li>
<li>
Configure your model in the Control Panel:
<ul className="mt-1 ml-4 space-y-1 list-disc">
<li>
<strong>Model ID:</strong> Use the exact Groq model name (e.g.,{' '}
<code className="bg-orange-100 px-1 rounded">llama-3.3-70b-versatile</code>)
</li>
<li>
<strong>Display Name:</strong> A friendly name (e.g., "Llama 3.3 70B")
</li>
<li>
<strong>Model Type:</strong> Select <code className="bg-orange-100 px-1 rounded">LLM</code> for chat models (most common for AI agents)
</li>
<li>
<strong>Context Window:</strong> Check{' '}
<a
href="https://console.groq.com/docs/models"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-orange-900"
>
Groq docs
</a>{' '}
(e.g., 128K for Llama 3.3)
</li>
<li>
<strong>Max Tokens:</strong> Typically 8192; check model docs
</li>
</ul>
</li>
</ol>
</div>
)}
{formData.provider === 'nvidia' && (
<div className="mt-2 p-3 rounded-md bg-green-50 border border-green-200">
<p className="text-sm font-medium text-green-800 mb-2">NVIDIA NIM Setup Steps:</p>
<ol className="text-sm text-green-700 space-y-2 list-decimal list-inside">
<li>
<a
href="https://build.nvidia.com"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-green-900"
>
Create an NVIDIA account
</a>{' '}
and generate an API key
</li>
<li>
Add your API key on the{' '}
<a href="/dashboard/api-keys" className="underline hover:text-green-900">
API Keys page
</a>
</li>
<li>
Configure your model in the Control Panel:
<ul className="mt-1 ml-4 space-y-1 list-disc">
<li>
<strong>Model ID:</strong> Use the NVIDIA model name (e.g.,{' '}
<code className="bg-green-100 px-1 rounded">meta/llama-3.1-70b-instruct</code>)
</li>
<li>
<strong>Display Name:</strong> A friendly name (e.g., "Llama 3.1 70B")
</li>
<li>
<strong>Model Type:</strong> Select <code className="bg-green-100 px-1 rounded">LLM</code> for chat models (most common for AI agents)
</li>
<li>
<strong>Context Window:</strong> Check the{' '}
<a
href="https://build.nvidia.com/models"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-green-900"
>
model page
</a>{' '}
(e.g., 128K for Llama 3.1)
</li>
<li>
<strong>Max Tokens:</strong> Typically 4096-8192; check model docs
</li>
</ul>
</li>
</ol>
</div>
)}
{formData.provider === 'local' && (
<p className="text-sm text-gray-600 mt-1">
Set this to any OpenAI Compatible API endpoint that doesn't require API authentication.
</p>
)}
{(formData.provider === 'ollama-dgx-x86' || formData.provider === 'ollama-macos') && (
<div className="mt-2 p-3 rounded-md bg-blue-50 border border-blue-200">
<p className="text-sm font-medium text-blue-800 mb-2">Ollama Setup Steps:</p>
<ol className="text-sm text-blue-700 space-y-2 list-decimal list-inside">
<li>
<a
href="https://ollama.com/download"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-blue-900"
>
Download and install
</a>{' '}
Ollama
</li>
<li>
Select and download your{' '}
<a
href="https://ollama.com/library"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-blue-900"
>
Model
</a>{' '}
(run: <code className="bg-blue-100 px-1 rounded">ollama pull model-name</code>)
</li>
<li>
Configure your model in the Control Panel:
<ul className="mt-1 ml-4 space-y-1 list-disc">
<li><strong>Model ID:</strong> Use the exact Ollama name with size tag (e.g., <code className="bg-blue-100 px-1 rounded">llama3.2:3b</code>, <code className="bg-blue-100 px-1 rounded">mistral:7b</code>)</li>
<li><strong>Display Name:</strong> A friendly name (e.g., "Llama 3.2 3B")</li>
<li><strong>Model Type:</strong> Select <code className="bg-blue-100 px-1 rounded">LLM</code> for chat models (most common for AI agents)</li>
<li><strong>Context Window:</strong> Find on the model's Ollama page (e.g., 128K for Llama 3.2)</li>
<li><strong>Max Tokens:</strong> Typically 2048-4096 for responses; check model docs</li>
</ul>
</li>
</ol>
</div>
)}
{testResult && (
<div className={`flex items-center gap-2 mt-2 p-2 rounded-md text-sm ${
testResult.status === 'healthy' ? 'bg-green-50 text-green-700 border border-green-200' :
testResult.status === 'degraded' ? 'bg-yellow-50 text-yellow-700 border border-yellow-200' :
'bg-red-50 text-red-700 border border-red-200'
}`}>
{testResult.status === 'healthy' && <CheckCircle className="w-4 h-4 flex-shrink-0" />}
{testResult.status === 'degraded' && <AlertTriangle className="w-4 h-4 flex-shrink-0" />}
{testResult.status === 'unhealthy' && <AlertCircle className="w-4 h-4 flex-shrink-0" />}
<div className="flex-1">
<span>{testResult.message}</span>
{testResult.latency_ms && (
<span className="ml-2 text-xs opacity-75">({testResult.latency_ms.toFixed(0)}ms)</span>
)}
</div>
</div>
)}
</div>
<div>
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => handleInputChange('description', e.target.value)}
placeholder="Brief description of the model..."
/>
</div>
</div>
<Separator />
{/* Technical Specifications */}
<div className="space-y-4">
<h3 className="text-lg font-medium">Technical Specifications</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="context_window">Context Window</Label>
<Input
id="context_window"
type="number"
value={formData.context_window}
onChange={(e) => handleInputChange('context_window', e.target.value)}
placeholder="128000"
/>
</div>
<div>
<Label htmlFor="max_tokens">Max Output Tokens</Label>
<Input
id="max_tokens"
type="number"
value={formData.max_tokens}
onChange={(e) => handleInputChange('max_tokens', e.target.value)}
placeholder="32768"
/>
</div>
</div>
{formData.model_type === 'embedding' && (
<div>
<Label htmlFor="dimensions">Embedding Dimensions</Label>
<Input
id="dimensions"
type="number"
value={formData.dimensions}
onChange={(e) => handleInputChange('dimensions', e.target.value)}
placeholder="1024"
/>
</div>
)}
</div>
<Separator />
{/* Tenant Access Info */}
<Alert>
<Users className="h-4 w-4" />
<AlertTitle>Automatic Tenant Access</AlertTitle>
<AlertDescription>
This model will be automatically assigned to all tenants with default rate limits (1000 requests/minute).
You can customize per-tenant rate limits after creation via the Edit Model dialog.
</AlertDescription>
</Alert>
</div>
<DialogFooter>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button onClick={handleSubmit}>
Add Model
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,873 @@
"use client";
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
import { useToast } from '@/components/ui/use-toast';
import {
Cpu,
TestTube,
RefreshCw,
CheckCircle,
AlertCircle,
ExternalLink,
Users,
Loader2
} from 'lucide-react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
interface CustomEndpoint {
provider: string;
name: string;
endpoint: string;
enabled: boolean;
health_status: 'healthy' | 'unhealthy' | 'testing' | 'unknown';
description: string;
is_external: boolean;
requires_api_key: boolean;
last_test?: string;
is_custom?: boolean;
model_type?: 'llm' | 'embedding' | 'both';
}
interface ModelConfig {
model_id: string;
name: string;
provider: string;
model_type: string;
endpoint: string;
description: string | null;
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;
is_compound?: boolean;
};
}
interface TenantRateLimitConfig {
id: number;
tenant_id: number;
tenant_name: string;
tenant_domain: string;
model_id: string;
is_enabled: boolean;
rate_limits: {
requests_per_minute: number;
max_tokens_per_request?: number;
concurrent_requests?: number;
max_cost_per_hour?: number;
};
}
interface EditModelDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
model: ModelConfig | null;
onModelUpdated: () => void;
}
export default function EditModelDialog({
open,
onOpenChange,
model,
onModelUpdated
}: EditModelDialogProps) {
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<{
success: boolean;
message: string;
} | null>(null);
const [customEndpoints, setCustomEndpoints] = useState<CustomEndpoint[]>([]);
// Tenant rate limits state
const [tenantConfigs, setTenantConfigs] = useState<TenantRateLimitConfig[]>([]);
const [loadingTenantConfigs, setLoadingTenantConfigs] = useState(false);
const [saving, setSaving] = useState(false);
const [editedRateLimits, setEditedRateLimits] = useState<Record<number, { requests_per_minute: string; is_enabled: boolean }>>({});
const [formData, setFormData] = useState({
model_id: '',
name: '',
provider: '',
model_type: '',
endpoint: '',
description: '',
context_window: '',
max_tokens: '',
dimensions: '',
});
const { toast } = useToast();
// Load custom endpoints from localStorage
useEffect(() => {
const loadCustomEndpoints = () => {
try {
const stored = localStorage.getItem('custom_endpoints');
if (stored) {
setCustomEndpoints(JSON.parse(stored));
}
} catch (error) {
console.error('Failed to load custom endpoints:', error);
}
};
if (open) {
loadCustomEndpoints();
}
}, [open]);
// Load tenant rate limit configurations when dialog opens
useEffect(() => {
const loadTenantConfigs = async () => {
if (!model || !open) return;
setLoadingTenantConfigs(true);
try {
const response = await fetch(`/api/v1/models/tenant-rate-limits/${encodeURIComponent(model.model_id)}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
'Content-Type': 'application/json',
},
});
if (response.ok) {
const data = await response.json();
setTenantConfigs(data.tenant_configs || []);
// Initialize edited values
const initialEdits: Record<number, { requests_per_minute: string; is_enabled: boolean }> = {};
(data.tenant_configs || []).forEach((config: TenantRateLimitConfig) => {
initialEdits[config.tenant_id] = {
requests_per_minute: config.rate_limits.requests_per_minute.toString(),
is_enabled: config.is_enabled,
};
});
setEditedRateLimits(initialEdits);
}
} catch (error) {
console.error('Failed to load tenant configs:', error);
} finally {
setLoadingTenantConfigs(false);
}
};
loadTenantConfigs();
}, [model, open]);
// Reset form when model changes
useEffect(() => {
if (model && open) {
setFormData({
model_id: model.model_id,
name: model.name,
provider: model.provider,
model_type: model.model_type,
endpoint: model.endpoint,
description: model.description || '',
context_window: model.specifications.context_window?.toString() || '',
max_tokens: model.specifications.max_tokens?.toString() || '',
dimensions: model.specifications.dimensions?.toString() || '',
});
setTestResult(null);
}
}, [model, open]);
const handleInputChange = (field: string, value: any) => {
setFormData(prev => ({ ...prev, [field]: value }));
};
// Handle tenant rate limit changes
const handleTenantRateLimitChange = (tenantId: number, field: 'requests_per_minute' | 'is_enabled', value: string | boolean) => {
setEditedRateLimits(prev => ({
...prev,
[tenantId]: {
...prev[tenantId],
[field]: value,
}
}));
};
// Get changed tenant rate limits
const getChangedTenantRateLimits = () => {
const changes: Array<{ tenantId: number; requests_per_minute: number; is_enabled: boolean }> = [];
for (const config of tenantConfigs) {
const edited = editedRateLimits[config.tenant_id];
if (edited) {
const hasChanges =
edited.requests_per_minute !== config.rate_limits.requests_per_minute.toString() ||
edited.is_enabled !== config.is_enabled;
if (hasChanges) {
changes.push({
tenantId: config.tenant_id,
requests_per_minute: parseInt(edited.requests_per_minute) || 1000,
is_enabled: edited.is_enabled,
});
}
}
}
return changes;
};
const handleProviderChange = (providerId: string) => {
console.log('Provider changed to:', providerId);
console.log('Available custom endpoints:', customEndpoints);
// Check if this is a custom endpoint
const customEndpoint = customEndpoints.find(ep => ep.provider === providerId);
console.log('Found custom endpoint:', customEndpoint);
if (customEndpoint) {
// Selected a configured endpoint - keep the endpoint ID as provider for Select consistency
console.log('Setting endpoint URL to:', customEndpoint.endpoint);
setFormData(prev => ({
...prev,
provider: providerId, // Use the endpoint ID to maintain Select consistency
endpoint: customEndpoint.endpoint, // Auto-fill the configured URL
model_type: customEndpoint.model_type || prev.model_type
}));
} else {
// Selected a default provider
setFormData(prev => {
const updated = { ...prev, provider: providerId };
// Auto-populate default endpoints
switch (providerId) {
case 'nvidia':
updated.endpoint = 'https://integrate.api.nvidia.com/v1/chat/completions';
break;
case 'groq':
updated.endpoint = 'https://api.groq.com/openai/v1/chat/completions';
break;
case 'ollama-dgx-x86':
updated.endpoint = 'http://ollama-host:11434/v1/chat/completions';
break;
case 'ollama-macos':
updated.endpoint = 'http://host.docker.internal:11434/v1/chat/completions';
break;
case 'local':
updated.endpoint = 'http://localhost:8000/v1/chat/completions';
break;
default:
updated.endpoint = '';
}
return updated;
});
}
};
const handleTestEndpoint = async () => {
if (!formData.endpoint) {
toast({
title: "Missing Endpoint",
description: "Please enter an endpoint URL to test",
variant: "destructive",
});
return;
}
setTesting(true);
setTestResult(null);
try {
// Test the specific model endpoint
const response = await fetch(`/api/v1/models/${encodeURIComponent(formData.model_id)}/test`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
'Content-Type': 'application/json',
},
});
const result = await response.json();
setTestResult({
success: result.healthy || false,
message: result.error || "Endpoint is responding correctly"
});
} catch (error) {
setTestResult({
success: false,
message: "Connection test failed"
});
} finally {
setTesting(false);
}
};
const handleSubmit = async () => {
setSaving(true);
try {
// Prepare submission data - use form data directly for now
const submissionData = { ...formData };
// Note: Pricing (cost, is_compound) is managed on the Billing page, not here
const updateData = {
model_id: submissionData.model_id,
name: submissionData.name,
provider: submissionData.provider,
model_type: submissionData.model_type,
endpoint: submissionData.endpoint,
description: submissionData.description || null,
specifications: {
context_window: submissionData.context_window ? parseInt(submissionData.context_window) : null,
max_tokens: submissionData.max_tokens ? parseInt(submissionData.max_tokens) : null,
dimensions: submissionData.dimensions ? parseInt(submissionData.dimensions) : null,
},
};
// Update model configuration
const response = await fetch(`/api/v1/models/${encodeURIComponent(formData.model_id)}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(updateData)
});
if (!response.ok) {
const errorData = await response.text();
toast({
title: "Failed to Update Model",
description: `Server returned ${response.status}: ${errorData.substring(0, 100)}`,
variant: "destructive",
});
return;
}
// Save any changed tenant rate limits
const rateLimitChanges = getChangedTenantRateLimits();
const rateLimitErrors: string[] = [];
for (const change of rateLimitChanges) {
try {
const rateLimitResponse = await fetch(
`/api/v1/models/tenant-rate-limits/${encodeURIComponent(formData.model_id)}/${change.tenantId}`,
{
method: 'PATCH',
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
requests_per_minute: change.requests_per_minute,
is_enabled: change.is_enabled,
}),
}
);
if (!rateLimitResponse.ok) {
rateLimitErrors.push(`Tenant ${change.tenantId}`);
}
} catch {
rateLimitErrors.push(`Tenant ${change.tenantId}`);
}
}
// Show appropriate toast
if (rateLimitErrors.length > 0) {
toast({
title: "Model Updated with Warnings",
description: `Model saved, but failed to update rate limits for: ${rateLimitErrors.join(', ')}`,
variant: "destructive",
});
} else {
toast({
title: "Model Updated",
description: rateLimitChanges.length > 0
? `Successfully updated ${formData.name} and ${rateLimitChanges.length} tenant rate limit(s)`
: `Successfully updated ${formData.name}`,
});
}
onModelUpdated();
onOpenChange(false);
} catch (error) {
toast({
title: "Network Error",
description: error instanceof Error ? error.message : "Could not connect to server",
variant: "destructive",
});
} finally {
setSaving(false);
}
};
if (!model) {
return null;
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Cpu className="w-5 h-5" />
Edit Model: {model.model_id}
</DialogTitle>
<DialogDescription>
Update the configuration for this model. Changes will be synced across all clusters.
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
{/* Basic Information */}
<div className="space-y-4">
<h3 className="text-lg font-medium">Basic Information</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="model_id">Model ID *</Label>
<Input
id="model_id"
value={formData.model_id}
onChange={(e) => handleInputChange('model_id', e.target.value)}
placeholder="e.g., gemma3, llama-3.3-70b"
/>
</div>
<div>
<Label htmlFor="name">Display Name *</Label>
<Input
id="name"
value={formData.name}
onChange={(e) => handleInputChange('name', e.target.value)}
placeholder="Llama 3.3 70B Versatile"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="provider">Provider *</Label>
<Select value={formData.provider} onValueChange={(value) => handleProviderChange(value)}>
<SelectTrigger>
<SelectValue placeholder="Select provider/endpoint" />
</SelectTrigger>
<SelectContent>
{/* Default providers */}
<SelectItem key="nvidia" value="nvidia">NVIDIA NIM (build.nvidia.com)</SelectItem>
<SelectItem key="ollama-dgx-x86" value="ollama-dgx-x86">Local Ollama (Ubuntu x86 / DGX ARM)</SelectItem>
<SelectItem key="ollama-macos" value="ollama-macos">Local Ollama (macOS Apple Silicon)</SelectItem>
<SelectItem key="groq" value="groq">Groq (api.groq.com)</SelectItem>
<SelectItem key="local" value="local">Custom Endpoint</SelectItem>
{/* Custom configured endpoints */}
{customEndpoints.length > 0 && (
<>
<SelectItem key="separator" value="separator" disabled className="text-xs text-muted-foreground font-medium px-2 py-1">
Configured Endpoints
</SelectItem>
{customEndpoints.map((endpoint) => (
<SelectItem key={endpoint.provider} value={endpoint.provider}>
{endpoint.name} ({endpoint.provider})
{endpoint.model_type && (
<span className="ml-2 text-xs text-muted-foreground">
[{endpoint.model_type}]
</span>
)}
</SelectItem>
))}
</>
)}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="model_type">Model Type *</Label>
<Select value={formData.model_type} onValueChange={(value) => handleInputChange('model_type', value)}>
<SelectTrigger>
<SelectValue placeholder="Select model type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="llm">Language Model (LLM)</SelectItem>
<SelectItem value="embedding">Embedding Model</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<div>
<Label htmlFor="endpoint">Endpoint URL *</Label>
<div className="flex gap-2">
<Input
id="endpoint"
value={formData.endpoint}
onChange={(e) => handleInputChange('endpoint', e.target.value)}
placeholder="https://api.groq.com/openai/v1/chat/completions"
className={formData.provider === 'local' ? "border-green-200 bg-green-50" : "border-purple-200 bg-purple-50"}
/>
<Button
type="button"
variant="outline"
onClick={handleTestEndpoint}
disabled={!formData.endpoint || testing}
>
{testing ? (
<RefreshCw className="w-4 h-4 animate-spin" />
) : (
<TestTube className="w-4 h-4" />
)}
</Button>
</div>
{testResult && (
<div className={`flex items-center gap-2 mt-2 text-sm ${testResult.success ? 'text-green-600' : 'text-red-600'}`}>
{testResult.success ? (
<CheckCircle className="w-4 h-4" />
) : (
<AlertCircle className="w-4 h-4" />
)}
{testResult.message}
</div>
)}
{formData.provider === 'groq' && (
<div className="mt-2 p-3 rounded-md bg-orange-50 border border-orange-200">
<p className="text-sm font-medium text-orange-800 mb-2">Groq Setup Steps:</p>
<ol className="text-sm text-orange-700 space-y-2 list-decimal list-inside">
<li>
<a
href="https://console.groq.com"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-orange-900"
>
Create a Groq account
</a>{' '}
and{' '}
<a
href="https://console.groq.com/keys"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-orange-900"
>
generate an API key
</a>
</li>
<li>
Add your API key on the{' '}
<a href="/dashboard/api-keys" className="underline hover:text-orange-900">
API Keys page
</a>
</li>
<li>
Configure your model in the Control Panel:
<ul className="mt-1 ml-4 space-y-1 list-disc">
<li>
<strong>Model ID:</strong> Use the exact Groq model name (e.g.,{' '}
<code className="bg-orange-100 px-1 rounded">llama-3.3-70b-versatile</code>)
</li>
<li>
<strong>Display Name:</strong> A friendly name (e.g., "Llama 3.3 70B")
</li>
<li>
<strong>Model Type:</strong> Select <code className="bg-orange-100 px-1 rounded">LLM</code> for chat models (most common for AI agents)
</li>
<li>
<strong>Context Window:</strong> Check{' '}
<a
href="https://console.groq.com/docs/models"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-orange-900"
>
Groq docs
</a>{' '}
(e.g., 128K for Llama 3.3)
</li>
<li>
<strong>Max Tokens:</strong> Typically 8192; check model docs
</li>
</ul>
</li>
</ol>
</div>
)}
{formData.provider === 'nvidia' && (
<div className="mt-2 p-3 rounded-md bg-green-50 border border-green-200">
<p className="text-sm font-medium text-green-800 mb-2">NVIDIA NIM Setup Steps:</p>
<ol className="text-sm text-green-700 space-y-2 list-decimal list-inside">
<li>
<a
href="https://build.nvidia.com"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-green-900"
>
Create an NVIDIA account
</a>{' '}
and generate an API key
</li>
<li>
Add your API key on the{' '}
<a href="/dashboard/api-keys" className="underline hover:text-green-900">
API Keys page
</a>
</li>
<li>
Configure your model in the Control Panel:
<ul className="mt-1 ml-4 space-y-1 list-disc">
<li>
<strong>Model ID:</strong> Use the NVIDIA model name (e.g.,{' '}
<code className="bg-green-100 px-1 rounded">meta/llama-3.1-70b-instruct</code>)
</li>
<li>
<strong>Display Name:</strong> A friendly name (e.g., "Llama 3.1 70B")
</li>
<li>
<strong>Model Type:</strong> Select <code className="bg-green-100 px-1 rounded">LLM</code> for chat models (most common for AI agents)
</li>
<li>
<strong>Context Window:</strong> Check the{' '}
<a
href="https://build.nvidia.com/models"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-green-900"
>
model page
</a>{' '}
(e.g., 128K for Llama 3.1)
</li>
<li>
<strong>Max Tokens:</strong> Typically 4096-8192; check model docs
</li>
</ul>
</li>
</ol>
</div>
)}
{formData.provider === 'local' && (
<p className="text-sm text-gray-600 mt-1">
Set this to any OpenAI Compatible API endpoint that doesn't require API authentication.
</p>
)}
{(formData.provider === 'ollama-dgx-x86' || formData.provider === 'ollama-macos') && (
<div className="mt-2 p-3 rounded-md bg-blue-50 border border-blue-200">
<p className="text-sm font-medium text-blue-800 mb-2">Ollama Setup Steps:</p>
<ol className="text-sm text-blue-700 space-y-2 list-decimal list-inside">
<li>
<a
href="https://ollama.com/download"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-blue-900"
>
Download and install
</a>{' '}
Ollama
</li>
<li>
Select and download your{' '}
<a
href="https://ollama.com/library"
target="_blank"
rel="noopener noreferrer"
className="underline hover:text-blue-900"
>
Model
</a>{' '}
(run: <code className="bg-blue-100 px-1 rounded">ollama pull model-name</code>)
</li>
<li>
Configure your model in the Control Panel:
<ul className="mt-1 ml-4 space-y-1 list-disc">
<li><strong>Model ID:</strong> Use the exact Ollama name with size tag (e.g., <code className="bg-blue-100 px-1 rounded">llama3.2:3b</code>, <code className="bg-blue-100 px-1 rounded">mistral:7b</code>)</li>
<li><strong>Display Name:</strong> A friendly name (e.g., "Llama 3.2 3B")</li>
<li><strong>Model Type:</strong> Select <code className="bg-blue-100 px-1 rounded">LLM</code> for chat models (most common for AI agents)</li>
<li><strong>Context Window:</strong> Find on the model's Ollama page (e.g., 128K for Llama 3.2)</li>
<li><strong>Max Tokens:</strong> Typically 2048-4096 for responses; check model docs</li>
</ul>
</li>
</ol>
</div>
)}
</div>
<div>
<Label htmlFor="description">Description</Label>
<Textarea
id="description"
value={formData.description}
onChange={(e) => handleInputChange('description', e.target.value)}
placeholder="Brief description of the model..."
/>
</div>
</div>
<Separator />
{/* Technical Specifications */}
<div className="space-y-4">
<h3 className="text-lg font-medium">Technical Specifications</h3>
<div className="grid grid-cols-2 gap-4">
<div>
<Label htmlFor="context_window">Context Window</Label>
<Input
id="context_window"
type="number"
value={formData.context_window}
onChange={(e) => handleInputChange('context_window', e.target.value)}
placeholder="128000"
/>
</div>
<div>
<Label htmlFor="max_tokens">Max Output Tokens</Label>
<Input
id="max_tokens"
type="number"
value={formData.max_tokens}
onChange={(e) => handleInputChange('max_tokens', e.target.value)}
placeholder="32768"
/>
</div>
</div>
{formData.model_type === 'embedding' && (
<div>
<Label htmlFor="dimensions">Embedding Dimensions</Label>
<Input
id="dimensions"
type="number"
value={formData.dimensions}
onChange={(e) => handleInputChange('dimensions', e.target.value)}
placeholder="1024"
/>
</div>
)}
</div>
<Separator />
{/* Tenant Rate Limits */}
<div className="space-y-4">
<div className="flex items-center gap-2">
<Users className="w-5 h-5" />
<h3 className="text-lg font-medium">Tenant Rate Limits</h3>
</div>
<p className="text-sm text-muted-foreground">
Configure per-tenant rate limits for this model. All tenants are automatically assigned to new models.
</p>
{loadingTenantConfigs ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground">Loading tenant configurations...</span>
</div>
) : tenantConfigs.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
No tenants configured for this model yet.
</div>
) : (
<div className="border rounded-md">
<Table>
<TableHeader>
<TableRow>
<TableHead>Tenant</TableHead>
<TableHead>Enabled</TableHead>
<TableHead>Requests/Min</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tenantConfigs.map((config) => {
const edited = editedRateLimits[config.tenant_id];
return (
<TableRow key={config.tenant_id}>
<TableCell>
<div>
<div className="font-medium">{config.tenant_name}</div>
<div className="text-xs text-muted-foreground">{config.tenant_domain}</div>
</div>
</TableCell>
<TableCell>
<Switch
checked={edited?.is_enabled ?? config.is_enabled}
onCheckedChange={(checked) => handleTenantRateLimitChange(config.tenant_id, 'is_enabled', checked)}
/>
</TableCell>
<TableCell>
<Input
type="number"
className="w-24"
value={edited?.requests_per_minute ?? config.rate_limits.requests_per_minute}
onChange={(e) => handleTenantRateLimitChange(config.tenant_id, 'requests_per_minute', e.target.value)}
/>
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
)}
</div>
</div>
<DialogFooter>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={saving}>
Cancel
</Button>
<Button onClick={handleSubmit} disabled={saving}>
{saving ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Updating...
</>
) : (
'Update Model'
)}
</Button>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,338 @@
"use client";
import { useState, useEffect } from '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 {
CheckCircle,
AlertCircle,
Clock,
RefreshCw,
Zap,
Cpu,
Activity,
TrendingUp
} from 'lucide-react';
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, BarChart, Bar } from 'recharts';
interface HealthMetrics {
total_models: number;
healthy_models: number;
unhealthy_models: number;
unknown_models: number;
avg_latency: number;
uptime_percentage: number;
last_updated: string;
}
interface ModelHealth {
model_id: string;
name: string;
provider: string;
health_status: 'healthy' | 'unhealthy' | 'unknown';
latency_ms: number;
success_rate: number;
last_check: string;
error_message?: string;
uptime_24h: number;
}
// Mock data for charts
const latencyData = [
{ time: '00:00', groq: 120, bge_m3: 45 },
{ time: '04:00', groq: 135, bge_m3: 52 },
{ time: '08:00', groq: 180, bge_m3: 67 },
{ time: '12:00', groq: 220, bge_m3: 89 },
{ time: '16:00', groq: 195, bge_m3: 71 },
{ time: '20:00', groq: 165, bge_m3: 58 },
];
const requestVolumeData = [
{ hour: '00', requests: 120 },
{ hour: '04', requests: 89 },
{ hour: '08', requests: 456 },
{ hour: '12', requests: 892 },
{ hour: '16', requests: 743 },
{ hour: '20', requests: 567 },
];
export default function ModelHealthDashboard() {
const [metrics, setMetrics] = useState<HealthMetrics | null>(null);
const [modelHealth, setModelHealth] = useState<ModelHealth[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
useEffect(() => {
loadHealthData();
}, []);
const loadHealthData = async () => {
setLoading(true);
// Mock data - replace with API calls
await new Promise(resolve => setTimeout(resolve, 1000));
const mockMetrics: HealthMetrics = {
total_models: 20,
healthy_models: 18,
unhealthy_models: 1,
unknown_models: 1,
avg_latency: 167,
uptime_percentage: 99.2,
last_updated: new Date().toISOString()
};
const mockModelHealth: ModelHealth[] = [
{
model_id: "llama-3.3-70b-versatile",
name: "Llama 3.3 70B Versatile",
provider: "groq",
health_status: "healthy",
latency_ms: 234,
success_rate: 99.8,
last_check: new Date(Date.now() - 30000).toISOString(),
uptime_24h: 99.9
},
{
model_id: "bge-m3",
name: "BGE-M3 Embeddings",
provider: "external",
health_status: "healthy",
latency_ms: 67,
success_rate: 100.0,
last_check: new Date(Date.now() - 15000).toISOString(),
uptime_24h: 99.5
},
{
model_id: "whisper-large-v3",
name: "Whisper Large v3",
provider: "groq",
health_status: "unhealthy",
latency_ms: 0,
success_rate: 87.2,
last_check: new Date(Date.now() - 120000).toISOString(),
error_message: "API rate limit exceeded",
uptime_24h: 87.2
},
{
model_id: "llama-3.1-405b-reasoning",
name: "Llama 3.1 405B Reasoning",
provider: "groq",
health_status: "unknown",
latency_ms: 0,
success_rate: 0,
last_check: new Date(Date.now() - 300000).toISOString(),
uptime_24h: 0
}
];
setMetrics(mockMetrics);
setModelHealth(mockModelHealth);
setLoading(false);
};
const handleRefresh = async () => {
setRefreshing(true);
await loadHealthData();
setRefreshing(false);
};
const getStatusIcon = (status: string) => {
switch (status) {
case 'healthy':
return <CheckCircle className="w-4 h-4 text-green-600" />;
case 'unhealthy':
return <AlertCircle className="w-4 h-4 text-red-600" />;
default:
return <Clock className="w-4 h-4 text-yellow-600" />;
}
};
const getStatusBadge = (status: string) => {
const variants: Record<string, "default" | "secondary" | "destructive"> = {
healthy: "default",
unhealthy: "destructive",
unknown: "secondary"
};
return (
<Badge variant={variants[status] || "secondary"} className="flex items-center gap-1">
{getStatusIcon(status)}
{status}
</Badge>
);
};
const getUptimeColor = (uptime: number) => {
if (uptime >= 99) return "text-green-600";
if (uptime >= 95) return "text-yellow-600";
return "text-red-600";
};
if (loading) {
return <div className="flex items-center justify-center p-8">Loading health data...</div>;
}
return (
<div className="space-y-6">
{/* Header with Refresh */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold">Model Health Overview</h2>
<p className="text-muted-foreground">
Last updated: {metrics?.last_updated ? new Date(metrics.last_updated).toLocaleString() : 'Never'}
</p>
</div>
<Button onClick={handleRefresh} disabled={refreshing} variant="outline">
<RefreshCw className={`w-4 h-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
Refresh
</Button>
</div>
{/* Metrics Cards */}
<div className="grid grid-cols-1 md:grid-cols-4 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">{metrics?.total_models}</div>
<div className="flex gap-2 text-xs text-muted-foreground">
<span className="text-green-600">{metrics?.healthy_models} healthy</span>
<span className="text-red-600">{metrics?.unhealthy_models} unhealthy</span>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">System Uptime</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">{metrics?.uptime_percentage}%</div>
<Progress value={metrics?.uptime_percentage} className="h-2 mt-2" />
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Avg Latency</CardTitle>
<Zap className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{metrics?.avg_latency}ms</div>
<p className="text-xs text-muted-foreground">Across all models</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Health Score</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-green-600">
{metrics ? Math.round((metrics.healthy_models / metrics.total_models) * 100) : 0}%
</div>
<p className="text-xs text-muted-foreground">Models responding</p>
</CardContent>
</Card>
</div>
{/* Charts */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card>
<CardHeader>
<CardTitle>Latency Trends (24h)</CardTitle>
<CardDescription>Response times by provider</CardDescription>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={latencyData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="time" />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="groq" stroke="#8884d8" strokeWidth={2} />
<Line type="monotone" dataKey="bge_m3" stroke="#82ca9d" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Request Volume (24h)</CardTitle>
<CardDescription>Total requests per hour</CardDescription>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={requestVolumeData}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="hour" />
<YAxis />
<Tooltip />
<Bar dataKey="requests" fill="#8884d8" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
{/* Individual Model Health */}
<Card>
<CardHeader>
<CardTitle>Individual Model Status</CardTitle>
<CardDescription>Detailed health information for each model</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{modelHealth.map((model) => (
<div key={model.model_id} className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex items-center gap-4">
<div>
<div className="flex items-center gap-2">
<h3 className="font-medium">{model.name}</h3>
<Badge variant="outline">{model.provider}</Badge>
</div>
<p className="text-sm text-muted-foreground">{model.model_id}</p>
{model.error_message && (
<p className="text-xs text-red-600 mt-1">{model.error_message}</p>
)}
</div>
</div>
<div className="flex items-center gap-6 text-sm">
<div className="text-center">
<div className="font-medium">{model.latency_ms}ms</div>
<div className="text-muted-foreground">Latency</div>
</div>
<div className="text-center">
<div className="font-medium">{model.success_rate}%</div>
<div className="text-muted-foreground">Success Rate</div>
</div>
<div className="text-center">
<div className={`font-medium ${getUptimeColor(model.uptime_24h)}`}>
{model.uptime_24h}%
</div>
<div className="text-muted-foreground">24h Uptime</div>
</div>
<div className="text-center">
{getStatusBadge(model.health_status)}
</div>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,696 @@
"use client";
import { useState, useEffect } from 'react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Label } from '@/components/ui/label';
import {
MoreHorizontal,
Edit,
TestTube,
Power,
PowerOff,
ExternalLink,
CheckCircle,
AlertCircle,
AlertTriangle,
Clock,
RotateCcw,
Trash2,
RefreshCw
} from 'lucide-react';
import { useToast } from '@/components/ui/use-toast';
import EditModelDialog from './EditModelDialog';
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;
is_compound?: 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;
is_compound?: boolean;
health_status: string;
};
}
interface ModelRegistryTableProps {
showArchived?: boolean;
models?: ModelConfig[];
loading?: boolean;
onModelUpdated?: () => void;
}
export default function ModelRegistryTable({
showArchived = false,
models: propModels,
loading: propLoading,
onModelUpdated
}: ModelRegistryTableProps) {
const [models, setModels] = useState<ModelConfig[]>([]);
const [loading, setLoading] = useState(true);
const [editingEndpoint, setEditingEndpoint] = useState<{
modelId: string;
currentEndpoint: string;
} | null>(null);
const [newEndpoint, setNewEndpoint] = useState('');
const [testingModel, setTestingModel] = useState<string | null>(null);
const [editingModel, setEditingModel] = useState<ModelConfig | null>(null);
const [deletingModel, setDeletingModel] = useState<string | null>(null);
const [restoringModel, setRestoringModel] = useState<string | null>(null);
const { toast } = useToast();
// Fetch models from API
// Use props if provided, otherwise fetch data
useEffect(() => {
if (propModels && propLoading !== undefined) {
// Use data from props (parent is managing the data)
setModels(propModels);
setLoading(propLoading);
} else {
// Fallback: fetch data if no props provided (legacy support)
const fetchModels = async () => {
try {
const response = await fetch('/api/v1/models?include_stats=true', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
const mappedModels: ModelConfig[] = data.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,
is_compound: model.status?.is_compound || 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,
}));
const filteredModels = showArchived
? mappedModels.filter(model => !model.is_active)
: mappedModels.filter(model => model.is_active);
setModels(filteredModels);
} catch (error) {
console.error('Failed to fetch models:', error);
toast({
title: "Failed to Load Models",
description: "Unable to fetch model configurations from the server",
variant: "destructive",
});
} finally {
setLoading(false);
}
};
fetchModels();
}
}, [propModels, propLoading, showArchived]);
const getStatusIcon = (status: string) => {
switch (status) {
case 'healthy':
return <CheckCircle className="w-4 h-4 text-green-600" />;
case 'unhealthy':
return <AlertCircle className="w-4 h-4 text-red-600" />;
default:
return <Clock className="w-4 h-4 text-yellow-600" />;
}
};
const getStatusBadge = (status: string) => {
const variants: Record<string, "default" | "secondary" | "destructive" | "outline"> = {
healthy: "default",
unhealthy: "destructive",
unknown: "secondary"
};
return (
<Badge variant={variants[status] || "secondary"} className="flex items-center gap-1">
{getStatusIcon(status)}
{status}
</Badge>
);
};
const getProviderBadge = (provider: string) => {
const colors: Record<string, string> = {
groq: "bg-purple-100 text-purple-800",
external: "bg-blue-100 text-blue-800",
openai: "bg-green-100 text-green-800",
anthropic: "bg-orange-100 text-orange-800"
};
return (
<Badge className={colors[provider] || "bg-gray-100 text-gray-800"}>
{provider}
</Badge>
);
};
const getModelTypeBadge = (type: string) => {
const colors: Record<string, string> = {
llm: "bg-indigo-100 text-indigo-800",
embedding: "bg-cyan-100 text-cyan-800",
audio: "bg-pink-100 text-pink-800",
tts: "bg-yellow-100 text-yellow-800"
};
return (
<Badge className={colors[type] || "bg-gray-100 text-gray-800"}>
{type}
</Badge>
);
};
const handleEditEndpoint = (modelId: string, currentEndpoint: string) => {
setEditingEndpoint({ modelId, currentEndpoint });
setNewEndpoint(currentEndpoint);
};
const handleSaveEndpoint = async () => {
if (!editingEndpoint) return;
try {
const response = await fetch(`/api/v1/models/${encodeURIComponent(editingEndpoint.modelId)}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({ endpoint: newEndpoint })
});
if (response.ok) {
setModels(models.map(model =>
model.model_id === editingEndpoint.modelId
? { ...model, endpoint: newEndpoint }
: model
));
toast({
title: "Endpoint Updated",
description: `Successfully updated endpoint for ${editingEndpoint.modelId}`,
});
}
} catch (error) {
toast({
title: "Update Failed",
description: "Failed to update model endpoint",
variant: "destructive",
});
}
setEditingEndpoint(null);
};
const handleTestModel = async (modelId: string) => {
setTestingModel(modelId);
try {
const response = await fetch(`/api/v1/models/${encodeURIComponent(modelId)}/test`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
'Content-Type': 'application/json'
}
});
const result = await response.json();
toast({
title: result.healthy ? "Model Healthy" : "Model Unhealthy",
description: result.error || "Model endpoint is responding correctly",
variant: result.healthy ? "default" : "destructive",
});
// Update model status
setModels(models.map(model =>
model.model_id === modelId
? {
...model,
health_status: result.healthy ? 'healthy' : 'unhealthy',
last_health_check: new Date().toISOString()
}
: model
));
} catch (error) {
toast({
title: "Test Failed",
description: "Failed to test model endpoint",
variant: "destructive",
});
}
setTestingModel(null);
};
const handleToggleModel = async (modelId: string, isActive: boolean) => {
try {
const response = await fetch(`/api/v1/models/${encodeURIComponent(modelId)}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
status: { is_active: !isActive }
})
});
if (response.ok) {
setModels(models.map(model =>
model.model_id === modelId
? { ...model, is_active: !isActive }
: model
));
toast({
title: isActive ? "Model Disabled" : "Model Enabled",
description: `Successfully ${isActive ? 'disabled' : 'enabled'} ${modelId}`,
});
}
} catch (error) {
toast({
title: "Toggle Failed",
description: "Failed to toggle model status",
variant: "destructive",
});
}
};
const handleEditModel = (model: ModelConfig) => {
setEditingModel(model);
};
const handleRestoreModel = async (modelId: string) => {
setRestoringModel(modelId);
};
const confirmRestoreModel = async () => {
if (!restoringModel) return;
try {
const response = await fetch(`/api/v1/models/${encodeURIComponent(restoringModel)}`, {
method: 'PATCH',
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
status: { is_active: true }
})
});
if (response.ok) {
// Remove from archived models list
setModels(models.filter(model => model.model_id !== restoringModel));
toast({
title: "Model Restored",
description: `Successfully restored ${restoringModel}. It's now available in the active models.`,
});
} else {
const errorText = await response.text();
console.error('Restore API error:', response.status, errorText);
toast({
title: "Restore Failed",
description: `Server returned ${response.status}: ${errorText.substring(0, 100)}`,
variant: "destructive",
});
}
} catch (error) {
console.error('Restore network error:', error);
toast({
title: "Restore Failed",
description: error instanceof Error ? error.message : "Network error occurred",
variant: "destructive",
});
}
setRestoringModel(null);
};
const handleDeleteModel = async (modelId: string) => {
setDeletingModel(modelId);
};
const confirmDeleteModel = async () => {
if (!deletingModel) return;
try {
const response = await fetch(`/api/v1/models/${encodeURIComponent(deletingModel)}`, {
method: 'DELETE',
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
'Content-Type': 'application/json'
}
});
if (response.ok) {
// Remove from models list
setModels(models.filter(model => model.model_id !== deletingModel));
if (onModelUpdated) {
onModelUpdated();
}
toast({
title: "Model Deleted",
description: `Successfully deleted ${deletingModel}. This action cannot be undone.`,
});
} else {
const errorText = await response.text();
console.error('Delete API error:', response.status, errorText);
toast({
title: "Delete Failed",
description: `Server returned ${response.status}: ${errorText.substring(0, 100)}`,
variant: "destructive",
});
}
} catch (error) {
console.error('Delete network error:', error);
toast({
title: "Delete Failed",
description: error instanceof Error ? error.message : "Network error occurred",
variant: "destructive",
});
}
setDeletingModel(null);
};
const handleModelUpdated = () => {
// Refetch models after successful update
const fetchModels = async () => {
try {
const response = await fetch('/api/v1/models?include_stats=true', {
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
'Content-Type': 'application/json',
},
});
if (response.ok) {
const data = await response.json();
const mappedModels: ModelConfig[] = data.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,
is_compound: model.status?.is_compound || 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);
}
} catch (error) {
console.error('Failed to refetch models:', error);
}
};
fetchModels();
};
if (loading) {
return <div className="flex items-center justify-center p-8">Loading models...</div>;
}
return (
<>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead>Model ID</TableHead>
<TableHead>Provider</TableHead>
<TableHead>Type</TableHead>
<TableHead>Endpoint</TableHead>
<TableHead>Context Window</TableHead>
<TableHead>Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{models.map((model) => (
<TableRow key={model.model_id}>
<TableCell className="font-medium">
<div className="flex flex-col">
<div className="flex items-center gap-2">
{model.name || model.model_id}
{!model.is_active && <PowerOff className="w-4 h-4 text-gray-400" />}
</div>
{model.name && (
<div className="text-xs text-gray-500 mt-1">
{model.model_id}
</div>
)}
</div>
</TableCell>
<TableCell>
{getProviderBadge(model.provider)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
{getModelTypeBadge(model.model_type)}
{model.is_compound && (
<Badge variant="outline" className="text-blue-600 border-blue-300 text-xs">Compound</Badge>
)}
</div>
</TableCell>
<TableCell className="max-w-xs">
<div className="flex items-center gap-2">
<code className="text-xs bg-gray-100 px-2 py-1 rounded truncate">
{model.endpoint}
</code>
<Button
variant="ghost"
size="sm"
onClick={() => handleEditEndpoint(model.model_id, model.endpoint)}
>
<Edit className="w-3 h-3" />
</Button>
</div>
</TableCell>
<TableCell>
{model.context_window?.toLocaleString() || 'N/A'}
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-8 w-8 p-0">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => handleEditModel(model)}
>
<Edit className="mr-2 h-4 w-4" />
Edit Model
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleToggleModel(model.model_id, model.is_active)}
>
{model.is_active ? (
<>
<PowerOff className="mr-2 h-4 w-4" />
Disable
</>
) : (
<>
<Power className="mr-2 h-4 w-4" />
Enable
</>
)}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => window.open(model.endpoint, '_blank')}
>
<ExternalLink className="mr-2 h-4 w-4" />
Open Endpoint
</DropdownMenuItem>
{showArchived ? (
<DropdownMenuItem
onClick={() => handleRestoreModel(model.model_id)}
className="text-green-600 focus:text-green-600"
>
<RotateCcw className="mr-2 h-4 w-4" />
Restore Model
</DropdownMenuItem>
) : (
<DropdownMenuItem
onClick={() => handleDeleteModel(model.model_id)}
className="text-red-600 focus:text-red-600"
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Model
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
{/* Edit Endpoint Dialog */}
<Dialog open={!!editingEndpoint} onOpenChange={() => setEditingEndpoint(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Edit Model Endpoint</DialogTitle>
<DialogDescription>
Update the endpoint URL for {editingEndpoint?.modelId}
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="endpoint">Endpoint URL</Label>
<Input
id="endpoint"
value={newEndpoint}
onChange={(e) => setNewEndpoint(e.target.value)}
placeholder="https://api.example.com/v1"
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditingEndpoint(null)}>
Cancel
</Button>
<Button onClick={handleSaveEndpoint}>
Save Changes
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Edit Model Dialog */}
<EditModelDialog
open={!!editingModel}
onOpenChange={(open) => !open && setEditingModel(null)}
model={editingModel}
onModelUpdated={handleModelUpdated}
/>
{/* Restore Confirmation Dialog */}
<Dialog open={!!restoringModel} onOpenChange={() => setRestoringModel(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Restore Model</DialogTitle>
<DialogDescription>
Are you sure you want to restore the model "{restoringModel}"? It will be moved back to the active models section.
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2">
<Button variant="outline" onClick={() => setRestoringModel(null)}>
Cancel
</Button>
<Button className="bg-green-600 hover:bg-green-700 text-white" onClick={confirmRestoreModel}>
Restore Model
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<Dialog open={!!deletingModel} onOpenChange={() => setDeletingModel(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Delete Model</DialogTitle>
<DialogDescription>
Are you sure you want to permanently delete the model "{deletingModel}"? This action cannot be undone and will remove the model from all systems.
</DialogDescription>
</DialogHeader>
<DialogFooter className="flex gap-2">
<Button variant="outline" onClick={() => setDeletingModel(null)}>
Cancel
</Button>
<Button className="bg-red-600 hover:bg-red-700 text-white" onClick={confirmDeleteModel}>
Delete Model
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,378 @@
"use client";
import { useState, useEffect } from 'react';
import { useToast } from '@/components/ui/use-toast';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
PieChart,
Pie,
Cell,
LineChart,
Line
} from 'recharts';
import {
TrendingUp,
Users,
Zap,
DollarSign,
RefreshCw,
Download
} from 'lucide-react';
interface AnalyticsData {
summary: {
total_requests: number;
total_tokens: number;
total_cost: number;
active_tenants: number;
};
usage_by_provider: Array<{
provider: string;
requests: number;
tokens: number;
cost: number;
}>;
top_models: Array<{
model: string;
requests: number;
tokens: string;
cost: number;
avg_latency: number;
success_rate: number;
}>;
hourly_usage: Array<{
hour: string;
requests: number;
tokens: number;
}>;
time_range: string;
}
const providerColors = ['#8884d8', '#82ca9d', '#ffc658', '#ff7300'];
export default function UsageAnalytics() {
const [timeRange, setTimeRange] = useState('24h');
const [loading, setLoading] = useState(false);
const [analyticsData, setAnalyticsData] = useState<AnalyticsData | null>(null);
const { toast } = useToast();
const handleExportData = () => {
// TODO: Implement CSV export
console.log('Exporting analytics data...');
};
const fetchAnalytics = async () => {
setLoading(true);
try {
const response = await fetch(`/api/v1/models/analytics/usage?time_range=${timeRange}`, {
headers: {
'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
setAnalyticsData(data);
} catch (error) {
console.error('Failed to fetch analytics:', error);
toast({
title: "Failed to Load Analytics",
description: "Unable to fetch usage analytics from the server",
variant: "destructive",
});
} finally {
setLoading(false);
}
};
const handleRefresh = () => {
fetchAnalytics();
};
// Fetch analytics on component mount and when time range changes
useEffect(() => {
fetchAnalytics();
}, [timeRange]);
const formatNumber = (num: number) => {
if (num >= 1000000) {
return (num / 1000000).toFixed(1) + 'M';
}
if (num >= 1000) {
return (num / 1000).toFixed(1) + 'K';
}
return num.toString();
};
const formatCurrency = (amount: number) => {
return `$${amount.toFixed(2)}`;
};
return (
<div className="space-y-6">
{/* Header with Controls */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-2xl font-bold">Usage Analytics</h2>
<p className="text-muted-foreground">Model usage patterns and performance metrics</p>
</div>
<div className="flex gap-2">
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-32">
<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 variant="outline" onClick={handleRefresh} disabled={loading}>
<RefreshCw className={`w-4 h-4 mr-2 ${loading ? 'animate-spin' : ''}`} />
Refresh
</Button>
<Button variant="outline" onClick={handleExportData}>
<Download className="w-4 h-4 mr-2" />
Export
</Button>
</div>
</div>
{/* Key Metrics */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Requests</CardTitle>
<TrendingUp className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{analyticsData ? formatNumber(analyticsData.summary.total_requests) : '0'}
</div>
<p className="text-xs text-muted-foreground">
<span className="text-green-600">+12.5%</span> from yesterday
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Tokens</CardTitle>
<Zap className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{analyticsData ? formatNumber(analyticsData.summary.total_tokens) : '0'}
</div>
<p className="text-xs text-muted-foreground">
<span className="text-green-600">+8.3%</span> from yesterday
</p>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Cost</CardTitle>
<DollarSign className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{analyticsData ? formatCurrency(analyticsData.summary.total_cost) : '$0.00'}
</div>
<p className="text-xs text-muted-foreground">
<span className="text-red-600">+15.2%</span> from yesterday
</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 Tenants</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">
{analyticsData ? analyticsData.summary.active_tenants : '0'}
</div>
<p className="text-xs text-muted-foreground">
<span className="text-green-600">+2</span> new this week
</p>
</CardContent>
</Card>
</div>
{/* Charts Section */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Usage by Provider */}
<Card>
<CardHeader>
<CardTitle>Usage by Provider</CardTitle>
<CardDescription>Requests and costs across providers</CardDescription>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<BarChart data={analyticsData?.usage_by_provider || []}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="provider" />
<YAxis />
<Tooltip formatter={(value, name) => {
if (name === 'cost') return formatCurrency(value as number);
return formatNumber(value as number);
}} />
<Bar dataKey="requests" fill="#8884d8" />
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
{/* Hourly Usage Pattern */}
<Card>
<CardHeader>
<CardTitle>Hourly Usage Pattern</CardTitle>
<CardDescription>Request volume over time</CardDescription>
</CardHeader>
<CardContent>
<ResponsiveContainer width="100%" height={300}>
<LineChart data={analyticsData?.hourly_usage || []}>
<CartesianGrid strokeDasharray="3 3" />
<XAxis dataKey="hour" />
<YAxis />
<Tooltip />
<Line type="monotone" dataKey="requests" stroke="#8884d8" strokeWidth={2} />
</LineChart>
</ResponsiveContainer>
</CardContent>
</Card>
</div>
{/* Top Models Table */}
<Card>
<CardHeader>
<CardTitle>Top Models by Usage</CardTitle>
<CardDescription>Detailed performance metrics for each model</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead>Model</TableHead>
<TableHead className="text-right">Requests</TableHead>
<TableHead className="text-right">Tokens</TableHead>
<TableHead className="text-right">Cost</TableHead>
<TableHead className="text-right">Avg Latency</TableHead>
<TableHead className="text-right">Success Rate</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{(analyticsData?.top_models || []).map((model) => (
<TableRow key={model.model}>
<TableCell className="font-medium">
<div className="flex flex-col">
<span>{model.model}</span>
{model.cost === 0 && (
<Badge variant="secondary" className="w-fit mt-1 text-xs">Free</Badge>
)}
</div>
</TableCell>
<TableCell className="text-right">{formatNumber(model.requests)}</TableCell>
<TableCell className="text-right">{model.tokens}</TableCell>
<TableCell className="text-right">
{model.cost === 0 ? (
<span className="text-green-600 font-medium">Free</span>
) : (
formatCurrency(model.cost)
)}
</TableCell>
<TableCell className="text-right">{model.avg_latency}ms</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<span>{model.success_rate}%</span>
{model.success_rate >= 99 ? (
<div className="w-2 h-2 bg-green-500 rounded-full"></div>
) : model.success_rate >= 95 ? (
<div className="w-2 h-2 bg-yellow-500 rounded-full"></div>
) : (
<div className="w-2 h-2 bg-red-500 rounded-full"></div>
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* Provider Summary Cards */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{(analyticsData?.usage_by_provider || []).map((provider, index) => (
<Card key={provider.provider}>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg">{provider.provider}</CardTitle>
<div
className="w-3 h-3 rounded-full"
style={{ backgroundColor: providerColors[index] }}
/>
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Requests</span>
<span className="font-medium">{formatNumber(provider.requests)}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Tokens</span>
<span className="font-medium">{provider.tokens}</span>
</div>
<div className="flex justify-between">
<span className="text-sm text-muted-foreground">Cost</span>
<span className="font-medium">
{provider.cost === 0 ? (
<span className="text-green-600">Free</span>
) : (
formatCurrency(provider.cost)
)}
</span>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,765 @@
'use client';
import { useEffect, useState } from '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 { Label } from '@/components/ui/label';
import { Progress } from '@/components/ui/progress';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import {
Cpu,
HardDrive,
Zap,
Brain,
Activity,
TrendingUp,
AlertTriangle,
CheckCircle,
DollarSign,
Scale,
RefreshCw,
Loader2,
Settings,
Download,
Upload,
Clock
} from 'lucide-react';
import toast from 'react-hot-toast';
interface ResourceUsage {
resource_type: string;
current_usage: number;
max_allowed: number;
percentage_used: number;
cost_accrued: number;
last_updated: string;
}
interface ResourceAlert {
id: number;
tenant_id: number;
resource_type: string;
alert_level: string;
message: string;
current_usage: number;
max_value: number;
percentage_used: number;
acknowledged: boolean;
acknowledged_by?: string;
acknowledged_at?: string;
created_at: string;
}
interface TenantCosts {
tenant_id: number;
period_start: string;
period_end: string;
total_cost: number;
costs_by_resource: Record<string, any>;
currency: string;
}
interface SystemOverview {
timestamp: string;
resource_overview: Record<string, any>;
total_tenants: number;
}
interface Tenant {
id: number;
name: string;
domain: string;
status: string;
}
export function ResourceManagement() {
const [selectedTenant, setSelectedTenant] = useState<Tenant | null>(null);
const [tenants, setTenants] = useState<Tenant[]>([]);
const [resourceUsage, setResourceUsage] = useState<Record<string, ResourceUsage>>({});
const [alerts, setAlerts] = useState<ResourceAlert[]>([]);
const [costs, setCosts] = useState<TenantCosts | null>(null);
const [systemOverview, setSystemOverview] = useState<SystemOverview | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [isScaling, setIsScaling] = useState(false);
const [scalingResource, setScalingResource] = useState('');
const [scaleFactor, setScaleFactor] = useState('1.0');
// Resource templates
const resourceTemplates = {
startup: {
name: 'Startup',
description: 'Basic resources for small teams',
monthly_cost: 99,
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' }
}
},
standard: {
name: 'Standard',
description: 'Standard resources for production',
monthly_cost: 299,
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' }
}
},
enterprise: {
name: 'Enterprise',
description: 'High-performance resources',
monthly_cost: 999,
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' }
}
}
};
useEffect(() => {
fetchTenants();
fetchSystemOverview();
fetchAlerts();
}, []);
useEffect(() => {
if (selectedTenant) {
fetchTenantResourceUsage();
fetchTenantCosts();
}
}, [selectedTenant]);
const fetchTenants = async () => {
try {
// Mock tenants for now - replace with actual API call
const mockTenants = [
{ id: 1, name: 'Acme Corp', domain: 'acme', status: 'active' },
{ id: 2, name: 'Tech Solutions', domain: 'techsol', status: 'active' },
{ id: 3, name: 'Startup Inc', domain: 'startup', status: 'pending' }
];
setTenants(mockTenants);
if (mockTenants.length > 0) {
setSelectedTenant(mockTenants[0]);
}
} catch (error) {
console.error('Failed to fetch tenants:', error);
toast.error('Failed to load tenants');
}
};
const fetchTenantResourceUsage = async () => {
if (!selectedTenant) return;
try {
setIsLoading(true);
// Mock resource usage data - replace with actual API call
const mockUsage: Record<string, ResourceUsage> = {
cpu: {
resource_type: 'cpu',
current_usage: 2.4,
max_allowed: 4.0,
percentage_used: 60,
cost_accrued: 24.0,
last_updated: new Date().toISOString()
},
memory: {
resource_type: 'memory',
current_usage: 6144,
max_allowed: 8192,
percentage_used: 75,
cost_accrued: 307.2,
last_updated: new Date().toISOString()
},
storage: {
resource_type: 'storage',
current_usage: 35000,
max_allowed: 51200,
percentage_used: 68,
cost_accrued: 350.0,
last_updated: new Date().toISOString()
},
api_calls: {
resource_type: 'api_calls',
current_usage: 38500,
max_allowed: 50000,
percentage_used: 77,
cost_accrued: 38.5,
last_updated: new Date().toISOString()
},
model_inference: {
resource_type: 'model_inference',
current_usage: 7800,
max_allowed: 10000,
percentage_used: 78,
cost_accrued: 15.6,
last_updated: new Date().toISOString()
}
};
setResourceUsage(mockUsage);
} catch (error) {
console.error('Failed to fetch resource usage:', error);
toast.error('Failed to load resource usage');
} finally {
setIsLoading(false);
}
};
const fetchTenantCosts = async () => {
if (!selectedTenant) return;
try {
// Mock cost data - replace with actual API call
const mockCosts: TenantCosts = {
tenant_id: selectedTenant.id,
period_start: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString(),
period_end: new Date().toISOString(),
total_cost: 735.3,
costs_by_resource: {
cpu: { total_usage: 72.0, total_cost: 24.0, usage_events: 720 },
memory: { total_usage: 6144.0, total_cost: 307.2, usage_events: 720 },
storage: { total_usage: 35000.0, total_cost: 350.0, usage_events: 30 },
api_calls: { total_usage: 1155000.0, total_cost: 38.5, usage_events: 1155 },
model_inference: { total_usage: 234000.0, total_cost: 15.6, usage_events: 234 }
},
currency: 'USD'
};
setCosts(mockCosts);
} catch (error) {
console.error('Failed to fetch tenant costs:', error);
toast.error('Failed to load cost data');
}
};
const fetchSystemOverview = async () => {
try {
// Mock system overview - replace with actual API call
const mockOverview: SystemOverview = {
timestamp: new Date().toISOString(),
resource_overview: {
cpu: { total_usage: 12.8, total_allocated: 20.0, utilization_percentage: 64.0, tenant_count: 3 },
memory: { total_usage: 18432, total_allocated: 32768, utilization_percentage: 56.25, tenant_count: 3 },
storage: { total_usage: 125000, total_allocated: 204800, utilization_percentage: 61.04, tenant_count: 3 },
api_calls: { total_usage: 145000, total_allocated: 300000, utilization_percentage: 48.33, tenant_count: 3 }
},
total_tenants: 3
};
setSystemOverview(mockOverview);
} catch (error) {
console.error('Failed to fetch system overview:', error);
toast.error('Failed to load system overview');
}
};
const fetchAlerts = async () => {
try {
// Mock alerts - replace with actual API call
const mockAlerts: ResourceAlert[] = [
{
id: 1,
tenant_id: 1,
resource_type: 'memory',
alert_level: 'warning',
message: 'Memory usage at 75.0%',
current_usage: 6144,
max_value: 8192,
percentage_used: 75.0,
acknowledged: false,
created_at: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString()
},
{
id: 2,
tenant_id: 2,
resource_type: 'api_calls',
alert_level: 'critical',
message: 'API calls usage at 95.0%',
current_usage: 47500,
max_value: 50000,
percentage_used: 95.0,
acknowledged: false,
created_at: new Date(Date.now() - 30 * 60 * 1000).toISOString()
}
];
setAlerts(mockAlerts);
} catch (error) {
console.error('Failed to fetch alerts:', error);
toast.error('Failed to load alerts');
}
};
const handleScaleResources = async () => {
if (!selectedTenant || !scalingResource || !scaleFactor) {
toast.error('Please select a resource and scale factor');
return;
}
try {
setIsScaling(true);
// Mock scaling API call - replace with actual API call
await new Promise(resolve => setTimeout(resolve, 2000));
toast.success(`Scaled ${scalingResource} by ${scaleFactor}x successfully`);
// Refresh data
await fetchTenantResourceUsage();
setScalingResource('');
setScaleFactor('1.0');
} catch (error) {
console.error('Failed to scale resources:', error);
toast.error('Failed to scale resources');
} finally {
setIsScaling(false);
}
};
const acknowledgeAlert = async (alertId: number) => {
try {
// Mock acknowledge API call - replace with actual API call
await new Promise(resolve => setTimeout(resolve, 500));
setAlerts(prev => prev.map(alert =>
alert.id === alertId
? { ...alert, acknowledged: true, acknowledged_at: new Date().toISOString() }
: alert
));
toast.success('Alert acknowledged');
} catch (error) {
console.error('Failed to acknowledge alert:', error);
toast.error('Failed to acknowledge alert');
}
};
const getResourceIcon = (resourceType: string) => {
switch (resourceType) {
case 'cpu':
return <Cpu className="h-5 w-5" />;
case 'memory':
return <Cpu className="h-5 w-5" />;
case 'storage':
return <HardDrive className="h-5 w-5" />;
case 'api_calls':
return <Zap className="h-5 w-5" />;
case 'model_inference':
return <Brain className="h-5 w-5" />;
case 'gpu_time':
return <Activity className="h-5 w-5" />;
default:
return <Settings className="h-5 w-5" />;
}
};
const getResourceName = (resourceType: string) => {
switch (resourceType) {
case 'cpu':
return 'CPU';
case 'memory':
return 'Memory';
case 'storage':
return 'Storage';
case 'api_calls':
return 'API Calls';
case 'model_inference':
return 'Model Inference';
case 'gpu_time':
return 'GPU Time';
default:
return resourceType;
}
};
const getResourceUnit = (resourceType: string) => {
switch (resourceType) {
case 'cpu':
return 'cores';
case 'memory':
return 'MB';
case 'storage':
return 'MB';
case 'api_calls':
return 'calls/hour';
case 'model_inference':
return 'tokens';
case 'gpu_time':
return 'minutes';
default:
return 'units';
}
};
const getAlertBadge = (level: string) => {
switch (level) {
case 'critical':
return <Badge variant="destructive">Critical</Badge>;
case 'warning':
return <Badge variant="secondary" className="bg-yellow-100 text-yellow-800">Warning</Badge>;
case 'info':
return <Badge variant="secondary">Info</Badge>;
default:
return <Badge variant="secondary">{level}</Badge>;
}
};
if (isLoading && Object.keys(resourceUsage).length === 0) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="flex items-center space-x-2">
<Loader2 className="h-6 w-6 animate-spin" />
<span className="text-muted-foreground">Loading resource data...</span>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h2 className="text-3xl font-bold">Resource Management</h2>
<p className="text-muted-foreground">
Monitor and manage resource allocation across all tenants
</p>
</div>
<div className="flex items-center space-x-4">
<Select value={selectedTenant?.id.toString()} onValueChange={(value) => {
const tenant = tenants.find(t => t.id.toString() === value);
if (tenant) setSelectedTenant(tenant);
}}>
<SelectTrigger className="w-48">
<SelectValue placeholder="Select tenant" />
</SelectTrigger>
<SelectContent>
{tenants.map((tenant) => (
<SelectItem key={tenant.id} value={tenant.id.toString()}>
{tenant.name} ({tenant.domain})
</SelectItem>
))}
</SelectContent>
</Select>
<Button variant="secondary" onClick={() => {
fetchTenantResourceUsage();
fetchSystemOverview();
fetchAlerts();
}}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
</div>
<Tabs defaultValue="usage" className="space-y-4">
<TabsList>
<TabsTrigger value="usage">Resource Usage</TabsTrigger>
<TabsTrigger value="costs">Cost Analysis</TabsTrigger>
<TabsTrigger value="alerts">Alerts</TabsTrigger>
<TabsTrigger value="system">System Overview</TabsTrigger>
</TabsList>
<TabsContent value="usage" className="space-y-4">
{selectedTenant && (
<>
{/* Current Tenant Resources */}
<Card>
<CardHeader>
<CardTitle>
Resource Usage - {selectedTenant.name}
</CardTitle>
<CardDescription>
Current resource utilization and limits
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(resourceUsage).map(([resourceType, usage]) => (
<Card key={resourceType} className="relative">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
<div className="flex items-center space-x-2">
{getResourceIcon(resourceType)}
<span>{getResourceName(resourceType)}</span>
</div>
</CardTitle>
<div className="text-xs text-muted-foreground">
${usage.cost_accrued.toFixed(2)}
</div>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span>
{usage.current_usage.toLocaleString()} / {usage.max_allowed.toLocaleString()} {getResourceUnit(resourceType)}
</span>
<span className="font-medium">
{usage.percentage_used.toFixed(1)}%
</span>
</div>
<Progress
value={usage.percentage_used}
className={`h-2 ${
usage.percentage_used >= 95
? 'bg-red-100'
: usage.percentage_used >= 80
? 'bg-yellow-100'
: 'bg-green-100'
}`}
/>
<div className="text-xs text-muted-foreground">
Last updated: {new Date(usage.last_updated).toLocaleTimeString()}
</div>
</div>
</CardContent>
</Card>
))}
</div>
</CardContent>
</Card>
{/* Resource Scaling */}
<Card>
<CardHeader>
<CardTitle>Resource Scaling</CardTitle>
<CardDescription>
Scale resources up or down for {selectedTenant.name}
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-end">
<div>
<Label htmlFor="scaling-resource">Resource Type</Label>
<Select value={scalingResource} onValueChange={setScalingResource}>
<SelectTrigger>
<SelectValue placeholder="Select resource" />
</SelectTrigger>
<SelectContent>
{Object.keys(resourceUsage).map((resourceType) => (
<SelectItem key={resourceType} value={resourceType}>
{getResourceName(resourceType)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="scale-factor">Scale Factor</Label>
<Input
id="scale-factor"
type="number"
min="0.1"
max="10.0"
step="0.1"
value={scaleFactor}
onChange={(e) => setScaleFactor((e as React.ChangeEvent<HTMLInputElement>).target.value)}
placeholder="1.5"
/>
</div>
<Button
onClick={handleScaleResources}
disabled={isScaling || !scalingResource || !scaleFactor}
>
{isScaling ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Scaling...
</>
) : (
<>
<Scale className="h-4 w-4 mr-2" />
Scale Resource
</>
)}
</Button>
</div>
</CardContent>
</Card>
</>
)}
</TabsContent>
<TabsContent value="costs" className="space-y-4">
{costs && (
<Card>
<CardHeader>
<CardTitle>Cost Breakdown - Last 30 Days</CardTitle>
<CardDescription>
Resource costs for {selectedTenant?.name}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
<div className="flex items-center justify-between p-4 bg-muted rounded-lg">
<div>
<div className="text-2xl font-bold">
${costs.total_cost.toFixed(2)} {costs.currency}
</div>
<div className="text-sm text-muted-foreground">
Total cost for period
</div>
</div>
<DollarSign className="h-8 w-8 text-muted-foreground" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Object.entries(costs.costs_by_resource).map(([resourceType, data]: [string, any]) => (
<Card key={resourceType}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
<div className="flex items-center space-x-2">
{getResourceIcon(resourceType)}
<span>{getResourceName(resourceType)}</span>
</div>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-1">
<div className="text-2xl font-bold">
${data.total_cost.toFixed(2)}
</div>
<div className="text-xs text-muted-foreground">
{data.total_usage.toLocaleString()} {getResourceUnit(resourceType)}
</div>
<div className="text-xs text-muted-foreground">
{data.usage_events} events
</div>
</div>
</CardContent>
</Card>
))}
</div>
</div>
</CardContent>
</Card>
)}
</TabsContent>
<TabsContent value="alerts" className="space-y-4">
<Card>
<CardHeader>
<CardTitle>Resource Alerts</CardTitle>
<CardDescription>
Recent resource usage alerts across all tenants
</CardDescription>
</CardHeader>
<CardContent>
{alerts.length === 0 ? (
<div className="text-center py-8">
<CheckCircle className="h-12 w-12 text-green-600 mx-auto mb-4" />
<p className="text-muted-foreground">No active alerts</p>
</div>
) : (
<div className="space-y-3">
{alerts.map((alert) => (
<div
key={alert.id}
className={`p-4 rounded-lg border ${
alert.acknowledged ? 'bg-muted/50' : 'bg-background'
}`}
>
<div className="flex items-start justify-between">
<div className="space-y-1">
<div className="flex items-center space-x-2">
{getAlertBadge(alert.alert_level)}
{getResourceIcon(alert.resource_type)}
<span className="font-medium">
{getResourceName(alert.resource_type)}
</span>
<span className="text-sm text-muted-foreground">
- Tenant {alert.tenant_id}
</span>
</div>
<p className="text-sm">{alert.message}</p>
<div className="text-xs text-muted-foreground">
<Clock className="h-3 w-3 inline mr-1" />
{new Date(alert.created_at).toLocaleString()}
{alert.acknowledged && (
<span className="ml-4">
Acknowledged {alert.acknowledged_at &&
`at ${new Date(alert.acknowledged_at).toLocaleString()}`}
</span>
)}
</div>
</div>
{!alert.acknowledged && (
<Button
variant="secondary"
size="sm"
onClick={() => acknowledgeAlert(alert.id)}
>
Acknowledge
</Button>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
<TabsContent value="system" className="space-y-4">
{systemOverview && (
<Card>
<CardHeader>
<CardTitle>System Resource Overview</CardTitle>
<CardDescription>
Aggregate resource usage across all {systemOverview.total_tenants} tenants
</CardDescription>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{Object.entries(systemOverview.resource_overview).map(([resourceType, data]: [string, any]) => (
<Card key={resourceType}>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">
<div className="flex items-center space-x-2">
{getResourceIcon(resourceType)}
<span>{getResourceName(resourceType)}</span>
</div>
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<div className="text-2xl font-bold">
{data.utilization_percentage.toFixed(1)}%
</div>
<Progress value={data.utilization_percentage} className="h-2" />
<div className="text-xs text-muted-foreground space-y-1">
<div>
{data.total_usage.toLocaleString()} / {data.total_allocated.toLocaleString()} {getResourceUnit(resourceType)}
</div>
<div>
{data.tenant_count} tenants using
</div>
</div>
</div>
</CardContent>
</Card>
))}
</div>
</CardContent>
</Card>
)}
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,80 @@
'use client';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { AlertTriangle, Clock } from 'lucide-react';
interface SessionTimeoutModalProps {
open: boolean;
remainingTime: number; // in seconds
onAcknowledge: () => void;
}
/**
* Session Expiration Notice Modal (NIST SP 800-63B AAL2)
*
* Informational modal that appears 30 minutes before the 12-hour absolute
* session timeout. This is NOT for idle timeout (which resets with activity).
*
* The absolute timeout cannot be extended - users must re-authenticate after
* 12 hours regardless of activity. This notice gives users time to save work.
*/
export function SessionTimeoutModal({
open,
remainingTime,
onAcknowledge,
}: SessionTimeoutModalProps) {
// Format time as mm:ss
const formatTime = (seconds: number): string => {
const minutes = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${minutes}:${secs.toString().padStart(2, '0')}`;
};
return (
<Dialog open={open} onOpenChange={() => {/* Prevent closing by clicking outside */}}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-amber-600">
<AlertTriangle className="w-5 h-5" />
Session Ending Soon
</DialogTitle>
</DialogHeader>
<div className="px-6 py-4 space-y-4">
<DialogDescription>
For security, your session will end in:
</DialogDescription>
<div className="flex items-center justify-center gap-2 py-4">
<Clock className="w-6 h-6 text-amber-600" />
<span className="text-3xl font-bold text-amber-600 font-mono">
{formatTime(remainingTime)}
</span>
</div>
<DialogDescription className="text-center text-sm">
Please save any unsaved work. You will need to log in again after your session ends.
This is a security requirement and cannot be extended.
</DialogDescription>
</div>
<DialogFooter className="justify-center">
<Button
variant="default"
onClick={onAcknowledge}
>
I Understand
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,433 @@
'use client';
import { useState, useEffect } from 'react';
import { useAuthStore } from '@/stores/auth-store';
import { enableTFA, verifyTFASetup, disableTFA, getTFAStatus } from '@/services/tfa';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { AlertCircle, CheckCircle, Copy, Loader2 } from 'lucide-react';
import toast from 'react-hot-toast';
interface TFAStatus {
tfa_enabled: boolean;
tfa_required: boolean;
tfa_status: string; // "disabled", "enabled", "enforced"
}
export function TFASettings() {
const { user } = useAuthStore();
const [tfaStatus, setTfaStatus] = useState<TFAStatus>({
tfa_enabled: false,
tfa_required: false,
tfa_status: 'disabled',
});
const [isLoading, setIsLoading] = useState(false);
// Enable TFA modal state
const [showEnableModal, setShowEnableModal] = useState(false);
const [qrCodeUri, setQrCodeUri] = useState('');
const [manualEntryKey, setManualEntryKey] = useState('');
const [setupCode, setSetupCode] = useState('');
const [setupStep, setSetupStep] = useState<'qr' | 'verify'>('qr');
const [setupError, setSetupError] = useState('');
// Disable TFA modal state
const [showDisableModal, setShowDisableModal] = useState(false);
const [disablePassword, setDisablePassword] = useState('');
const [disableError, setDisableError] = useState('');
// Load TFA status on mount
useEffect(() => {
loadTFAStatus();
}, []);
const loadTFAStatus = async () => {
try {
const status = await getTFAStatus();
setTfaStatus(status);
} catch (err: any) {
console.error('Failed to load TFA status:', err);
}
};
const handleToggleChange = (checked: boolean) => {
if (checked) {
// Enable TFA
handleEnableTFA();
} else {
// Disable TFA
if (tfaStatus.tfa_required) {
toast.error('Cannot disable 2FA - it is required by your administrator');
return;
}
setShowDisableModal(true);
}
};
const handleEnableTFA = async () => {
setIsLoading(true);
setSetupError('');
try {
const result = await enableTFA();
setQrCodeUri(result.qr_code_uri);
setManualEntryKey(result.manual_entry_key);
setSetupStep('qr');
setShowEnableModal(true);
} catch (err: any) {
toast.error(err.message || 'Failed to enable 2FA');
} finally {
setIsLoading(false);
}
};
const handleVerifySetup = async (e: React.FormEvent) => {
e.preventDefault();
setSetupError('');
// Validate code format (6 digits)
if (!/^\d{6}$/.test(setupCode)) {
setSetupError('Please enter a valid 6-digit code');
return;
}
setIsLoading(true);
try {
await verifyTFASetup(setupCode);
// Success! Close modal and refresh status
setShowEnableModal(false);
setSetupCode('');
setSetupStep('qr');
toast.success('2FA enabled successfully!');
await loadTFAStatus();
} catch (err: any) {
setSetupError(err.message || 'Invalid verification code');
} finally {
setIsLoading(false);
}
};
const handleDisableTFA = async (e: React.FormEvent) => {
e.preventDefault();
setDisableError('');
if (!disablePassword) {
setDisableError('Password is required');
return;
}
setIsLoading(true);
try {
await disableTFA(disablePassword);
// Success! Close modal and refresh status
setShowDisableModal(false);
setDisablePassword('');
toast.success('2FA disabled successfully');
await loadTFAStatus();
} catch (err: any) {
setDisableError(err.message || 'Failed to disable 2FA');
} finally {
setIsLoading(false);
}
};
const copyToClipboard = (text: string) => {
navigator.clipboard.writeText(text);
toast.success('Copied to clipboard!');
};
const getStatusBadge = () => {
const { tfa_status } = tfaStatus;
if (tfa_status === 'enforced') {
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200">
Enforced
</span>
);
} else if (tfa_status === 'enabled') {
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Enabled
</span>
);
} else {
return (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-800 dark:text-gray-200">
Disabled
</span>
);
}
};
return (
<div className="space-y-4">
{/* Header */}
<div>
<h3 className="text-lg font-semibold">
Two-Factor Authentication
</h3>
<p className="text-sm text-muted-foreground mt-1">
Add an extra layer of security to your account using a time-based one-time password (TOTP).
</p>
</div>
{/* Status and Toggle */}
<div className="border rounded-lg p-6 space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<div>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">
Status:
</span>
{getStatusBadge()}
</div>
{tfaStatus.tfa_required && (
<p className="text-xs text-orange-600 dark:text-orange-400 mt-1">
2FA is required by your administrator
</p>
)}
</div>
</div>
<Switch
checked={tfaStatus.tfa_enabled}
onCheckedChange={handleToggleChange}
disabled={isLoading || (tfaStatus.tfa_enabled && tfaStatus.tfa_required)}
/>
</div>
{/* Info text */}
{!tfaStatus.tfa_enabled && !tfaStatus.tfa_required && (
<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 text-blue-900 dark:text-blue-100">
<strong>Recommended:</strong> Enable 2FA to protect your account with Google Authenticator or any TOTP-compatible app.
</p>
</div>
)}
{tfaStatus.tfa_enabled && (
<div className="bg-green-50 dark:bg-green-950 border border-green-200 dark:border-green-800 rounded-lg p-4">
<p className="text-sm text-green-900 dark:text-green-100">
Your account is protected with 2FA. You'll need to enter a code from your authenticator app each time you log in.
</p>
</div>
)}
</div>
{/* Enable TFA Modal */}
<Dialog open={showEnableModal} onOpenChange={setShowEnableModal}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Setup Two-Factor Authentication</DialogTitle>
<DialogDescription>
{setupStep === 'qr'
? 'Scan the QR code with your authenticator app'
: 'Enter the 6-digit code from your authenticator app'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{setupStep === 'qr' && (
<>
{/* QR Code Display */}
<div className="bg-white p-4 rounded-lg border-2 border-border flex justify-center">
<img
src={qrCodeUri}
alt="QR Code"
className="w-48 h-48"
/>
</div>
{/* Manual Entry Key */}
<div>
<Label className="text-sm font-medium mb-2">
Manual Entry Key
</Label>
<div className="flex items-center gap-2 mt-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>
{/* Instructions */}
<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 text-blue-900 dark:text-blue-100">
Instructions:
</p>
<ol className="text-sm text-blue-800 dark:text-blue-200 mt-2 ml-4 list-decimal space-y-1">
<li>Download Google Authenticator or any TOTP app</li>
<li>Scan the QR code or enter the manual key as shown above</li>
<li>Click "Next" to verify your setup</li>
</ol>
</div>
</>
)}
{setupStep === 'verify' && (
<form onSubmit={handleVerifySetup} className="space-y-4">
<div>
<Label className="text-sm font-medium mb-2">
6-Digit Code
</Label>
<Input
type="text"
value={setupCode}
onChange={(e) => setSetupCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
placeholder="000000"
maxLength={6}
autoFocus
disabled={isLoading}
className="text-center text-2xl tracking-widest font-mono mt-2"
/>
</div>
{setupError && (
<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">{setupError}</p>
</div>
</div>
)}
</form>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setShowEnableModal(false);
setSetupStep('qr');
setSetupCode('');
setSetupError('');
}}
disabled={isLoading}
>
Cancel
</Button>
{setupStep === 'qr' ? (
<Button
onClick={() => setSetupStep('verify')}
>
Next
</Button>
) : (
<Button
onClick={handleVerifySetup}
disabled={setupCode.length !== 6 || isLoading}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Verifying...
</>
) : (
'Verify and Enable'
)}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
{/* Disable TFA Modal */}
<Dialog open={showDisableModal} onOpenChange={setShowDisableModal}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Disable Two-Factor Authentication</DialogTitle>
<DialogDescription>
Enter your password to confirm disabling 2FA
</DialogDescription>
</DialogHeader>
<form onSubmit={handleDisableTFA} className="space-y-4">
<div>
<Label htmlFor="disable-password">Password</Label>
<Input
id="disable-password"
type="password"
value={disablePassword}
onChange={(e) => setDisablePassword(e.target.value)}
placeholder="Enter your password"
autoFocus
disabled={isLoading}
className="mt-2"
/>
</div>
{disableError && (
<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">{disableError}</p>
</div>
</div>
)}
<div className="bg-yellow-50 dark:bg-yellow-950 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<p className="text-sm text-yellow-900 dark:text-yellow-100">
<strong>Warning:</strong> Disabling 2FA will make your account less secure.
You will only need your password to log in.
</p>
</div>
</form>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setShowDisableModal(false);
setDisablePassword('');
setDisableError('');
}}
disabled={isLoading}
>
Cancel
</Button>
<Button
variant="destructive"
onClick={handleDisableTFA}
disabled={!disablePassword || isLoading}
>
{isLoading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Disabling...
</>
) : (
'Disable 2FA'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,352 @@
'use client';
import { useEffect, useState } from 'react';
import { Download, Trash2, Upload, HardDrive, Loader2, AlertCircle } from 'lucide-react';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Progress } from '@/components/ui/progress';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Card, CardContent } from '@/components/ui/card';
import { systemApi } from '@/lib/api';
import toast from 'react-hot-toast';
interface Backup {
id: string;
uuid: string;
created_at: string;
backup_type: 'manual' | 'scheduled' | 'pre_update';
size: number;
version: string;
is_valid: boolean;
description?: string;
download_url?: string;
}
interface StorageInfo {
used: number;
total: number;
available: number;
}
export function BackupManager() {
const [backups, setBackups] = useState<Backup[]>([]);
const [storageInfo, setStorageInfo] = useState<StorageInfo>({
used: 0,
total: 100,
available: 100
});
const [isLoading, setIsLoading] = useState(true);
const [isCreating, setIsCreating] = useState(false);
const [operatingBackupId, setOperatingBackupId] = useState<string | null>(null);
useEffect(() => {
fetchBackups();
}, []);
const fetchBackups = async () => {
try {
setIsLoading(true);
const response = await systemApi.listBackups();
const data = response.data;
setBackups(data.backups || []);
if (data.storage) {
setStorageInfo(data.storage);
}
} catch (error) {
console.error('Failed to fetch backups:', error);
toast.error('Failed to load backups');
} finally {
setIsLoading(false);
}
};
const handleCreateBackup = async (type: 'manual' | 'scheduled' | 'pre_update') => {
setIsCreating(true);
try {
await systemApi.createBackup(type);
const typeLabel = type === 'pre_update' ? 'Pre-update' : type.charAt(0).toUpperCase() + type.slice(1);
toast.success(`${typeLabel} backup created successfully`);
fetchBackups();
} catch (error) {
console.error('Failed to create backup:', error);
toast.error('Failed to create backup');
} finally {
setIsCreating(false);
}
};
const handleDownloadBackup = async (backup: Backup) => {
try {
setOperatingBackupId(backup.uuid);
if (backup.download_url) {
// Create a download link
const link = document.createElement('a');
link.href = backup.download_url;
link.download = `backup-${backup.uuid}.tar.gz`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
toast.success('Backup download started');
} else {
toast.error('Download URL not available');
}
} catch (error) {
console.error('Failed to download backup:', error);
toast.error('Failed to download backup');
} finally {
setOperatingBackupId(null);
}
};
const handleRestoreBackup = async (backupId: string) => {
const confirmed = confirm(
'Are you sure you want to restore this backup? This will replace all current data and restart the system.'
);
if (!confirmed) return;
try {
setOperatingBackupId(backupId);
await systemApi.restoreBackup(backupId);
toast.success('Backup restore initiated. System will restart shortly...');
// Wait a few seconds then reload
setTimeout(() => {
window.location.reload();
}, 3000);
} catch (error) {
console.error('Failed to restore backup:', error);
toast.error('Failed to restore backup');
setOperatingBackupId(null);
}
};
const handleDeleteBackup = async (backupId: string) => {
const confirmed = confirm('Are you sure you want to delete this backup? This action cannot be undone.');
if (!confirmed) return;
try {
setOperatingBackupId(backupId);
await systemApi.deleteBackup(backupId);
toast.success('Backup deleted successfully');
fetchBackups();
} catch (error) {
console.error('Failed to delete backup:', error);
toast.error('Failed to delete backup');
} finally {
setOperatingBackupId(null);
}
};
const formatBytes = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + ' ' + sizes[i];
};
const formatDate = (dateString: string): string => {
const date = new Date(dateString);
return date.toLocaleString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
};
const getBackupTypeBadge = (type: string) => {
switch (type) {
case 'manual':
return <Badge className="bg-blue-600">Manual</Badge>;
case 'scheduled':
return <Badge className="bg-green-600">Scheduled</Badge>;
case 'pre_update':
return <Badge className="bg-purple-600">Pre-Update</Badge>;
default:
return <Badge>{type}</Badge>;
}
};
const getStatusBadge = (isValid: boolean) => {
if (isValid) {
return <Badge variant="default" className="bg-green-600">Valid</Badge>;
} else {
return <Badge variant="destructive">Invalid</Badge>;
}
};
const storagePercentage = (storageInfo.used / storageInfo.total) * 100;
if (isLoading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
<span>Loading backups...</span>
</div>
);
}
return (
<div className="space-y-6">
{/* Create Backup Button */}
<div className="flex justify-between items-center">
<div>
<h3 className="text-lg font-medium">Backup Management</h3>
<p className="text-sm text-muted-foreground">Create and manage system backups</p>
</div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button disabled={isCreating}>
{isCreating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Creating...
</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
Create Backup
</>
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={() => handleCreateBackup('manual')}>
<HardDrive className="mr-2 h-4 w-4" />
Manual Backup
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCreateBackup('scheduled')}>
<HardDrive className="mr-2 h-4 w-4" />
Scheduled Backup
</DropdownMenuItem>
<DropdownMenuItem onClick={() => handleCreateBackup('pre_update')}>
<HardDrive className="mr-2 h-4 w-4" />
Pre-Update Backup
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Backups Table */}
{backups.length > 0 ? (
<div className="border rounded-lg">
<Table>
<TableHeader>
<TableRow>
<TableHead>Date</TableHead>
<TableHead>Type</TableHead>
<TableHead>Size</TableHead>
<TableHead>Version</TableHead>
<TableHead>Status</TableHead>
<TableHead className="text-right">Actions</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{backups.map((backup) => (
<TableRow key={backup.id}>
<TableCell className="font-medium">
{formatDate(backup.created_at)}
</TableCell>
<TableCell>{getBackupTypeBadge(backup.backup_type)}</TableCell>
<TableCell>{formatBytes(backup.size || 0)}</TableCell>
<TableCell className="font-mono text-sm">v{backup.version || 'unknown'}</TableCell>
<TableCell>{getStatusBadge(backup.is_valid)}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end space-x-2">
{backup.is_valid && (
<>
<Button
variant="ghost"
size="sm"
onClick={() => handleDownloadBackup(backup)}
disabled={operatingBackupId === backup.uuid}
aria-label="Download backup"
>
{operatingBackupId === backup.uuid ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Download className="h-4 w-4" />
)}
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleRestoreBackup(backup.uuid)}
disabled={operatingBackupId === backup.uuid}
aria-label="Restore backup"
>
Restore
</Button>
</>
)}
<Button
variant="ghost"
size="sm"
onClick={() => handleDeleteBackup(backup.uuid)}
disabled={operatingBackupId === backup.uuid}
aria-label="Delete backup"
>
{operatingBackupId === backup.uuid ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4 text-red-600" />
)}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
) : (
<Card className="border-dashed">
<CardContent className="flex flex-col items-center justify-center py-12">
<AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">No backups found</h3>
<p className="text-sm text-muted-foreground mb-4 text-center">
Create your first backup to protect your data
</p>
</CardContent>
</Card>
)}
{/* Storage Usage */}
<Card>
<CardContent className="p-4">
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">Storage Usage</span>
<span className="text-muted-foreground">
{formatBytes(storageInfo.used)} / {formatBytes(storageInfo.total)}
</span>
</div>
<Progress value={storagePercentage} className="h-2" />
<p className="text-xs text-muted-foreground">
{formatBytes(storageInfo.available)} available
</p>
</div>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,148 @@
'use client';
import { useEffect, useState } from 'react';
import { AlertCircle, X, Download } from 'lucide-react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { systemApi } from '@/lib/api';
import toast from 'react-hot-toast';
import { UpdateModal } from './UpdateModal';
interface UpdateInfo {
available: boolean;
current_version: string;
latest_version: string;
update_type: 'major' | 'minor' | 'patch';
release_notes: string;
released_at: string;
}
const DISMISSAL_KEY = 'gt2_update_dismissed';
const CHECK_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
export function UpdateBanner() {
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
const [isDismissed, setIsDismissed] = useState(true);
const [showModal, setShowModal] = useState(false);
const [isChecking, setIsChecking] = useState(false);
useEffect(() => {
checkForUpdates();
// Set up periodic checking every 24 hours
const interval = setInterval(checkForUpdates, CHECK_INTERVAL);
return () => clearInterval(interval);
}, []);
const checkForUpdates = async () => {
try {
setIsChecking(true);
const response = await systemApi.checkUpdate();
const data = response.data as UpdateInfo;
if (data.available) {
setUpdateInfo(data);
// Check if this version was previously dismissed
const dismissalData = localStorage.getItem(DISMISSAL_KEY);
if (dismissalData) {
const { version, timestamp } = JSON.parse(dismissalData);
const now = Date.now();
const twentyFourHours = 24 * 60 * 60 * 1000;
// Show banner if it's a different version or 24h have passed
if (version !== data.latest_version || (now - timestamp) > twentyFourHours) {
setIsDismissed(false);
}
} else {
setIsDismissed(false);
}
} else {
setUpdateInfo(null);
setIsDismissed(true);
}
} catch (error) {
console.error('Failed to check for updates:', error);
// Silently fail - don't show error to user for background check
} finally {
setIsChecking(false);
}
};
const handleDismiss = () => {
if (updateInfo) {
const dismissalData = {
version: updateInfo.latest_version,
timestamp: Date.now()
};
localStorage.setItem(DISMISSAL_KEY, JSON.stringify(dismissalData));
setIsDismissed(true);
toast.success('Update notification dismissed for 24 hours');
}
};
const handleUpdateNow = () => {
setShowModal(true);
};
const getVariantClass = () => {
if (!updateInfo) return 'border-blue-500 bg-blue-50 text-blue-900';
switch (updateInfo.update_type) {
case 'major':
return 'border-red-500 bg-red-50 text-red-900';
case 'minor':
return 'border-amber-500 bg-amber-50 text-amber-900';
case 'patch':
return 'border-blue-500 bg-blue-50 text-blue-900';
default:
return 'border-blue-500 bg-blue-50 text-blue-900';
}
};
if (!updateInfo || isDismissed || isChecking) {
return null;
}
return (
<>
<Alert className={`mb-4 ${getVariantClass()} border-l-4`} role="alert" aria-live="polite">
<Download className="h-4 w-4" />
<AlertTitle className="flex items-center justify-between pr-6">
<span>Version {updateInfo.latest_version} available</span>
<Button
variant="ghost"
size="sm"
onClick={handleDismiss}
className="absolute right-2 top-2 h-6 w-6 p-0 hover:bg-transparent"
aria-label="Dismiss update notification"
>
<X className="h-4 w-4" />
</Button>
</AlertTitle>
<AlertDescription className="flex items-center justify-between">
<span>
A new version of GT 2.0 is available. Update from v{updateInfo.current_version} to v{updateInfo.latest_version}.
</span>
<Button
onClick={handleUpdateNow}
size="sm"
className="ml-4 shrink-0"
aria-label="Open update dialog"
>
Update Now
</Button>
</AlertDescription>
</Alert>
{showModal && updateInfo && (
<UpdateModal
updateInfo={updateInfo}
open={showModal}
onClose={() => setShowModal(false)}
/>
)}
</>
);
}

View File

@@ -0,0 +1,303 @@
'use client';
import { useState, useEffect } from 'react';
import { CheckCircle, XCircle, AlertCircle, Loader2, HardDrive, Database, Activity, Clock } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent } from '@/components/ui/card';
import { systemApi } from '@/lib/api';
import toast from 'react-hot-toast';
import { UpdateProgress } from './UpdateProgress';
interface UpdateInfo {
current_version: string;
latest_version: string;
update_type: 'major' | 'minor' | 'patch';
release_notes: string;
released_at: string;
}
interface ValidationCheck {
name: string;
backendName: string; // Backend uses snake_case names
status: 'pending' | 'checking' | 'passed' | 'failed';
message?: string;
icon: React.ReactNode;
}
interface UpdateModalProps {
updateInfo: UpdateInfo;
open: boolean;
onClose: () => void;
}
// Map backend check names to display names
const CHECK_NAME_MAP: Record<string, string> = {
'disk_space': 'Disk Space',
'container_health': 'Container Health',
'database_connectivity': 'Database Connectivity',
'recent_backup': 'Last Backup Age'
};
export function UpdateModal({ updateInfo, open, onClose }: UpdateModalProps) {
const [validationChecks, setValidationChecks] = useState<ValidationCheck[]>([
{
name: 'Disk Space',
backendName: 'disk_space',
status: 'pending',
icon: <HardDrive className="h-5 w-5" />
},
{
name: 'Container Health',
backendName: 'container_health',
status: 'pending',
icon: <Activity className="h-5 w-5" />
},
{
name: 'Database Connectivity',
backendName: 'database_connectivity',
status: 'pending',
icon: <Database className="h-5 w-5" />
},
{
name: 'Last Backup Age',
backendName: 'recent_backup',
status: 'pending',
icon: <Clock className="h-5 w-5" />
}
]);
const [createBackup, setCreateBackup] = useState(true);
const [isValidating, setIsValidating] = useState(false);
const [validationComplete, setValidationComplete] = useState(false);
const [updateStarted, setUpdateStarted] = useState(false);
const [updateId, setUpdateId] = useState<string | null>(null);
useEffect(() => {
if (open) {
runValidation();
}
}, [open]);
const runValidation = async () => {
setIsValidating(true);
try {
const response = await systemApi.validateUpdate(updateInfo.latest_version);
const validationResults = response.data;
// Update checks based on API response - match by backendName
const updatedChecks = validationChecks.map(check => {
const result = validationResults.checks.find((c: any) => c.name === check.backendName);
if (result) {
return {
...check,
status: result.passed ? 'passed' : 'failed',
message: result.message
};
}
return check;
});
setValidationChecks(updatedChecks);
setValidationComplete(true);
} catch (error) {
console.error('Validation failed:', error);
toast.error('Failed to validate system for update');
// Mark all checks as failed
const failedChecks = validationChecks.map(check => ({
...check,
status: 'failed' as const,
message: 'Validation check failed'
}));
setValidationChecks(failedChecks);
setValidationComplete(true);
} finally {
setIsValidating(false);
}
};
const handleStartUpdate = async () => {
if (!allChecksPassed) {
toast.error('Cannot start update: validation checks failed');
return;
}
try {
const response = await systemApi.startUpdate(updateInfo.latest_version, createBackup);
const data = response.data;
setUpdateId(data.update_id);
setUpdateStarted(true);
toast.success('Update started successfully');
} catch (error) {
console.error('Failed to start update:', error);
toast.error('Failed to start update');
}
};
const handleUpdateComplete = () => {
toast.success('Update completed successfully!');
setTimeout(() => {
window.location.reload();
}, 2000);
};
const handleUpdateFailed = () => {
toast.error('Update failed. Check logs for details.');
};
const getCheckIcon = (status: string) => {
switch (status) {
case 'passed':
return <CheckCircle className="h-5 w-5 text-green-600" />;
case 'failed':
return <XCircle className="h-5 w-5 text-red-600" />;
case 'checking':
return <Loader2 className="h-5 w-5 text-blue-600 animate-spin" />;
default:
return <AlertCircle className="h-5 w-5 text-gray-400" />;
}
};
const getCheckClass = (status: string) => {
switch (status) {
case 'passed':
return 'bg-green-50 border-green-200';
case 'failed':
return 'bg-red-50 border-red-200';
case 'checking':
return 'bg-blue-50 border-blue-200';
default:
return 'bg-gray-50 border-gray-200';
}
};
const getUpdateTypeBadge = () => {
switch (updateInfo.update_type) {
case 'major':
return <Badge className="bg-red-600">Major Update</Badge>;
case 'minor':
return <Badge className="bg-amber-600">Minor Update</Badge>;
case 'patch':
return <Badge className="bg-blue-600">Patch Update</Badge>;
default:
return <Badge>Update</Badge>;
}
};
const allChecksPassed = validationComplete && validationChecks.every(check => check.status === 'passed');
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center justify-between">
<span>Software Update Available</span>
{getUpdateTypeBadge()}
</DialogTitle>
<DialogDescription>
Update GT 2.0 from v{updateInfo.current_version} to v{updateInfo.latest_version}
</DialogDescription>
</DialogHeader>
{!updateStarted ? (
<div className="space-y-6">
{/* Release Notes */}
<div className="space-y-2">
<h3 className="font-medium text-sm">Release Notes</h3>
<Card>
<CardContent className="p-4 prose prose-sm max-w-none">
<div
className="text-sm text-muted-foreground whitespace-pre-wrap"
dangerouslySetInnerHTML={{ __html: updateInfo.release_notes }}
/>
</CardContent>
</Card>
</div>
{/* Pre-update Validation */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-medium text-sm">Pre-Update Validation</h3>
{isValidating && (
<span className="text-sm text-muted-foreground flex items-center">
<Loader2 className="h-4 w-4 animate-spin mr-2" />
Validating...
</span>
)}
</div>
{validationChecks.map((check, index) => (
<Card key={index} className={`${getCheckClass(check.status)} border`}>
<CardContent className="p-3">
<div className="flex items-center space-x-3">
<div>{check.icon}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="font-medium text-sm">{check.name}</span>
<div>{getCheckIcon(check.status)}</div>
</div>
{check.message && (
<p className="text-xs text-muted-foreground mt-1">{check.message}</p>
)}
</div>
</div>
</CardContent>
</Card>
))}
</div>
{/* Backup Option */}
<div className="flex items-center space-x-3 p-4 bg-blue-50 border border-blue-200 rounded-lg">
<input
type="checkbox"
id="create-backup"
checked={createBackup}
onChange={(e) => setCreateBackup(e.target.checked)}
className="h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-600"
/>
<label htmlFor="create-backup" className="text-sm font-medium cursor-pointer">
Create backup before update (recommended)
</label>
</div>
{/* Action Buttons */}
<div className="flex justify-end space-x-3">
<Button variant="outline" onClick={onClose}>
Cancel
</Button>
<Button
onClick={handleStartUpdate}
disabled={!allChecksPassed || isValidating}
>
{isValidating ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Validating...
</>
) : (
'Start Update'
)}
</Button>
</div>
</div>
) : (
<UpdateProgress
updateId={updateId!}
onComplete={handleUpdateComplete}
onFailed={handleUpdateFailed}
/>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,318 @@
'use client';
import { useEffect, useState } from 'react';
import { CheckCircle, XCircle, Loader2, ChevronDown, ChevronUp, AlertCircle } from 'lucide-react';
import { Progress } from '@/components/ui/progress';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { systemApi } from '@/lib/api';
import toast from 'react-hot-toast';
interface UpdateStage {
name: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed';
progress: number;
error?: string;
}
// Backend response format
interface BackendUpdateStatus {
uuid: string;
target_version: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed' | 'rolled_back';
started_at: string;
completed_at: string | null;
current_stage: string | null; // e.g., "creating_backup", "executing_update"
logs: Array<{ timestamp: string; level: string; message: string }>;
error_message: string | null;
backup_id: number | null;
}
// Frontend display format
interface UpdateStatus {
update_id: string;
status: 'pending' | 'in_progress' | 'completed' | 'failed' | 'rolled_back';
current_stage: number;
stages: UpdateStage[];
overall_progress: number;
logs: string[];
}
interface UpdateProgressProps {
updateId: string;
onComplete: () => void;
onFailed: () => void;
}
const STAGE_NAMES = [
'Creating Backup',
'Executing Update',
'Verifying Health'
];
// Map backend stage names to indices
const STAGE_MAP: Record<string, number> = {
'creating_backup': 0,
'executing_update': 1,
'completed': 2,
'failed': 2,
'rolling_back': 2,
'rolled_back': 2
};
const POLL_INTERVAL = 2000; // 2 seconds
// Transform backend response to frontend format
function transformStatus(backend: BackendUpdateStatus): UpdateStatus {
const currentStageIndex = backend.current_stage ? STAGE_MAP[backend.current_stage] ?? 1 : 0;
// Build stages array based on current progress
const stages: UpdateStage[] = STAGE_NAMES.map((name, index) => {
let stageStatus: UpdateStage['status'] = 'pending';
let progress = 0;
if (backend.status === 'completed' || backend.status === 'rolled_back') {
stageStatus = 'completed';
progress = 100;
} else if (backend.status === 'failed') {
if (index < currentStageIndex) {
stageStatus = 'completed';
progress = 100;
} else if (index === currentStageIndex) {
stageStatus = 'failed';
progress = 50;
}
} else if (index < currentStageIndex) {
stageStatus = 'completed';
progress = 100;
} else if (index === currentStageIndex) {
stageStatus = 'in_progress';
progress = 50;
}
return {
name,
status: stageStatus,
progress,
error: index === currentStageIndex && backend.status === 'failed' ? backend.error_message || undefined : undefined
};
});
// Calculate overall progress
let overallProgress = 0;
if (backend.status === 'completed' || backend.status === 'rolled_back') {
overallProgress = 100;
} else if (backend.status === 'failed') {
overallProgress = ((currentStageIndex + 0.5) / STAGE_NAMES.length) * 100;
} else {
overallProgress = ((currentStageIndex + 0.5) / STAGE_NAMES.length) * 100;
}
// Transform logs from objects to strings
const logs = backend.logs.map(log =>
`[${new Date(log.timestamp).toLocaleTimeString()}] [${log.level.toUpperCase()}] ${log.message}`
);
return {
update_id: backend.uuid,
status: backend.status,
current_stage: currentStageIndex,
stages,
overall_progress: overallProgress,
logs
};
}
export function UpdateProgress({ updateId, onComplete, onFailed }: UpdateProgressProps) {
const [status, setStatus] = useState<UpdateStatus | null>(null);
const [isLogsExpanded, setIsLogsExpanded] = useState(false);
const [isRollingBack, setIsRollingBack] = useState(false);
useEffect(() => {
fetchStatus();
const interval = setInterval(() => {
fetchStatus();
}, POLL_INTERVAL);
return () => clearInterval(interval);
}, [updateId]);
const fetchStatus = async () => {
try {
const response = await systemApi.getUpdateStatus(updateId);
const backendData = response.data as BackendUpdateStatus;
const transformedData = transformStatus(backendData);
setStatus(transformedData);
if (transformedData.status === 'completed') {
onComplete();
} else if (transformedData.status === 'failed') {
onFailed();
}
} catch (error) {
console.error('Failed to fetch update status:', error);
toast.error('Failed to fetch update status');
}
};
const handleRollback = async () => {
if (!confirm('Are you sure you want to rollback this update? This will restore the previous version.')) {
return;
}
setIsRollingBack(true);
try {
await systemApi.rollback(updateId);
toast.success('Rollback initiated successfully');
// Refresh status
fetchStatus();
} catch (error) {
console.error('Failed to initiate rollback:', error);
toast.error('Failed to initiate rollback');
} finally {
setIsRollingBack(false);
}
};
const getStageIcon = (stage: UpdateStage) => {
switch (stage.status) {
case 'completed':
return <CheckCircle className="h-5 w-5 text-green-600" />;
case 'failed':
return <XCircle className="h-5 w-5 text-red-600" />;
case 'in_progress':
return <Loader2 className="h-5 w-5 text-blue-600 animate-spin" />;
default:
return <div className="h-5 w-5 rounded-full border-2 border-gray-300" />;
}
};
const getStageClass = (stage: UpdateStage) => {
switch (stage.status) {
case 'completed':
return 'bg-green-50 border-green-200';
case 'failed':
return 'bg-red-50 border-red-200';
case 'in_progress':
return 'bg-blue-50 border-blue-200';
default:
return 'bg-gray-50 border-gray-200';
}
};
if (!status) {
return (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin mr-2" />
<span>Loading update status...</span>
</div>
);
}
const showRollbackButton = status.status === 'failed' && !isRollingBack;
const showCloseButton = status.status === 'completed';
return (
<div className="space-y-6" role="region" aria-label="Update progress">
{/* Overall Progress */}
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span className="font-medium">Overall Progress</span>
<span className="text-muted-foreground">{Math.round(status.overall_progress)}%</span>
</div>
<Progress value={status.overall_progress} className="h-3" />
</div>
{/* Stage Progress */}
<div className="space-y-3">
{status.stages.map((stage, index) => (
<Card key={index} className={`${getStageClass(stage)} border`}>
<CardContent className="p-4">
<div className="flex items-start space-x-3">
<div className="mt-0.5">{getStageIcon(stage)}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center justify-between">
<span className="font-medium">{stage.name}</span>
{stage.status === 'in_progress' && (
<span className="text-sm text-muted-foreground">{Math.round(stage.progress)}%</span>
)}
</div>
{stage.error && (
<div className="mt-2 text-sm text-red-600 flex items-start">
<AlertCircle className="h-4 w-4 mr-1 mt-0.5 shrink-0" />
<span>{stage.error}</span>
</div>
)}
{stage.status === 'in_progress' && (
<Progress value={stage.progress} className="h-2 mt-2" />
)}
</div>
</div>
</CardContent>
</Card>
))}
</div>
{/* Log Viewer */}
{status.logs.length > 0 && (
<div className="border rounded-lg overflow-hidden">
<button
onClick={() => setIsLogsExpanded(!isLogsExpanded)}
className="w-full px-4 py-3 bg-gray-50 hover:bg-gray-100 flex items-center justify-between text-sm font-medium transition-colors"
aria-expanded={isLogsExpanded}
aria-controls="update-logs"
>
<span>View Update Logs ({status.logs.length} entries)</span>
{isLogsExpanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
{isLogsExpanded && (
<div
id="update-logs"
className="bg-gray-900 text-gray-100 p-4 max-h-64 overflow-y-auto font-mono text-xs"
role="log"
aria-live="polite"
>
{status.logs.map((log, index) => (
<div key={index} className="py-0.5">
{log}
</div>
))}
</div>
)}
</div>
)}
{/* Action Buttons */}
{(showRollbackButton || showCloseButton) && (
<div className="flex justify-end space-x-3 pt-4">
{showRollbackButton && (
<Button
variant="destructive"
onClick={handleRollback}
disabled={isRollingBack}
>
{isRollingBack ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Rolling Back...
</>
) : (
'Rollback'
)}
</Button>
)}
{showCloseButton && (
<Button variant="default" onClick={onComplete}>
Close
</Button>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,104 @@
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Loader2, AlertCircle } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { TemplatePreview } from './TemplatePreview';
interface Template {
id: number;
name: string;
description: string;
resource_counts: {
models: number;
agents: number;
datasets: number;
};
}
interface ApplyTemplateModalProps {
open: boolean;
onClose: () => void;
template: Template;
onSuccess: () => void;
}
export function ApplyTemplateModal({
open,
onClose,
template,
onSuccess
}: ApplyTemplateModalProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleApply = async () => {
try {
setLoading(true);
setError(null);
const response = await fetch('/api/v1/templates/apply', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
template_id: template.id,
tenant_id: 1
})
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.detail || 'Failed to apply template');
}
const result = await response.json();
console.log('Template applied:', result);
onSuccess();
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Apply Template: {template.name}</DialogTitle>
<DialogDescription>
This will add the following resources to your tenant
</DialogDescription>
</DialogHeader>
<div className="py-4">
<TemplatePreview template={template} />
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button onClick={handleApply} disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Apply Template
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,125 @@
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Loader2, AlertCircle } from 'lucide-react';
import { Alert, AlertDescription } from '@/components/ui/alert';
interface ExportTemplateModalProps {
open: boolean;
onClose: () => void;
onSuccess: () => void;
}
export function ExportTemplateModal({
open,
onClose,
onSuccess
}: ExportTemplateModalProps) {
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const handleExport = async () => {
if (!name.trim()) {
setError('Template name is required');
return;
}
try {
setLoading(true);
setError(null);
const response = await fetch('/api/v1/templates/export', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
tenant_id: 1,
name: name.trim(),
description: description.trim()
})
});
if (!response.ok) {
const data = await response.json();
throw new Error(data.detail || 'Failed to export template');
}
const result = await response.json();
console.log('Template exported:', result);
setName('');
setDescription('');
onSuccess();
} catch (err: any) {
setError(err.message);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>Export Current Tenant as Template</DialogTitle>
<DialogDescription>
Save your current tenant configuration as a reusable template
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label htmlFor="template-name">Template Name *</Label>
<Input
id="template-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="e.g., Production Setup"
disabled={loading}
/>
</div>
<div className="space-y-2">
<Label htmlFor="template-description">Description</Label>
<Textarea
id="template-description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Describe what this template includes..."
rows={3}
disabled={loading}
/>
</div>
</div>
{error && (
<Alert variant="destructive">
<AlertCircle className="h-4 w-4" />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button onClick={handleExport} disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Export Template
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,49 @@
import { Badge } from '@/components/ui/badge';
import { Bot, Database, Cpu } from 'lucide-react';
interface Template {
id: number;
name: string;
resource_counts: {
models: number;
agents: number;
datasets: number;
};
}
interface TemplatePreviewProps {
template: Template;
}
export function TemplatePreview({ template }: TemplatePreviewProps) {
const { models, agents, datasets } = template.resource_counts;
return (
<div className="space-y-2">
<div className="text-sm text-muted-foreground">This template includes:</div>
<div className="flex flex-wrap gap-2">
{models > 0 && (
<Badge variant="secondary" className="flex items-center gap-1">
<Cpu className="h-3 w-3" />
{models} Model{models > 1 ? 's' : ''}
</Badge>
)}
{agents > 0 && (
<Badge variant="secondary" className="flex items-center gap-1">
<Bot className="h-3 w-3" />
{agents} Agent{agents > 1 ? 's' : ''}
</Badge>
)}
{datasets > 0 && (
<Badge variant="secondary" className="flex items-center gap-1">
<Database className="h-3 w-3" />
{datasets} Dataset{datasets > 1 ? 's' : ''}
</Badge>
)}
{models === 0 && agents === 0 && datasets === 0 && (
<Badge variant="outline">Empty Template</Badge>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,141 @@
"use client"
import * as React from "react"
import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"
import { cn } from "@/lib/utils"
import { buttonVariants } from "@/components/ui/button"
const AlertDialog = AlertDialogPrimitive.Root
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
const AlertDialogPortal = AlertDialogPrimitive.Portal
const AlertDialogOverlay = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Overlay
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
ref={ref}
/>
))
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
const AlertDialogContent = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
>(({ className, ...props }, ref) => (
<AlertDialogPortal>
<AlertDialogOverlay />
<AlertDialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
/>
</AlertDialogPortal>
))
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
const AlertDialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-2 text-center sm:text-left",
className
)}
{...props}
/>
)
AlertDialogHeader.displayName = "AlertDialogHeader"
const AlertDialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
AlertDialogFooter.displayName = "AlertDialogFooter"
const AlertDialogTitle = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Title
ref={ref}
className={cn("text-lg font-semibold", className)}
{...props}
/>
))
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
const AlertDialogDescription = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
AlertDialogDescription.displayName =
AlertDialogPrimitive.Description.displayName
const AlertDialogAction = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Action>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Action
ref={ref}
className={cn(buttonVariants(), className)}
{...props}
/>
))
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
const AlertDialogCancel = React.forwardRef<
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
>(({ className, ...props }, ref) => (
<AlertDialogPrimitive.Cancel
ref={ref}
className={cn(
buttonVariants({ variant: "outline" }),
"mt-2 sm:mt-0",
className
)}
{...props}
/>
))
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
export {
AlertDialog,
AlertDialogPortal,
AlertDialogOverlay,
AlertDialogTrigger,
AlertDialogContent,
AlertDialogHeader,
AlertDialogFooter,
AlertDialogTitle,
AlertDialogDescription,
AlertDialogAction,
AlertDialogCancel,
}

View File

@@ -0,0 +1,58 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@@ -0,0 +1,48 @@
import * as React from "react"
import * as AvatarPrimitive from "@radix-ui/react-avatar"
import { cn } from "@/lib/utils"
const Avatar = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Root>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Root
ref={ref}
className={cn(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
className
)}
{...props}
/>
))
Avatar.displayName = AvatarPrimitive.Root.displayName
const AvatarImage = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Image>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Image>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Image
ref={ref}
className={cn("aspect-square h-full w-full", className)}
{...props}
/>
))
AvatarImage.displayName = AvatarPrimitive.Image.displayName
const AvatarFallback = React.forwardRef<
React.ElementRef<typeof AvatarPrimitive.Fallback>,
React.ComponentPropsWithoutRef<typeof AvatarPrimitive.Fallback>
>(({ className, ...props }, ref) => (
<AvatarPrimitive.Fallback
ref={ref}
className={cn(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
className
)}
{...props}
/>
))
AvatarFallback.displayName = AvatarPrimitive.Fallback.displayName
export { Avatar, AvatarImage, AvatarFallback }

View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,68 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-xl border bg-card text-card-foreground shadow",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex flex-col space-y-1.5 p-6", className)} {...props} />
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn("font-semibold leading-none tracking-tight", className)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("flex items-center p-6 pt-0", className)} {...props} />
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,122 @@
"use client"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-background/80 backdrop-blur-sm data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,199 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto h-4 w-4" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,105 @@
'use client';
import React from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { AlertTriangle, RefreshCw } from 'lucide-react';
interface ErrorBoundaryState {
hasError: boolean;
error?: Error;
}
interface ErrorBoundaryProps {
children: React.ReactNode;
fallback?: React.ComponentType<{ error?: Error; resetError: () => void }>;
}
class ErrorBoundaryClass extends React.Component<ErrorBoundaryProps, ErrorBoundaryState> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Error caught by boundary:', error, errorInfo);
}
resetError = () => {
this.setState({ hasError: false, error: undefined });
};
render() {
if (this.state.hasError) {
if (this.props.fallback) {
const FallbackComponent = this.props.fallback;
return <FallbackComponent error={this.state.error} resetError={this.resetError} />;
}
return <DefaultErrorFallback error={this.state.error} resetError={this.resetError} />;
}
return this.props.children;
}
}
function DefaultErrorFallback({ error, resetError }: { error?: Error; resetError: () => void }) {
return (
<div className="flex items-center justify-center min-h-[400px] 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">Something went wrong</CardTitle>
<CardDescription>
An error occurred while loading this page. Please try refreshing.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{error && (
<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={resetError} 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>
);
}
// Hook version for functional components
export function useErrorBoundary() {
const [error, setError] = React.useState<Error | null>(null);
const resetError = React.useCallback(() => {
setError(null);
}, []);
const catchError = React.useCallback((error: Error) => {
setError(error);
}, []);
React.useEffect(() => {
if (error) {
throw error;
}
}, [error]);
return { catchError, resetError };
}
export { ErrorBoundaryClass as ErrorBoundary };

View File

@@ -0,0 +1,25 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,78 @@
'use client';
import { Loader2 } from 'lucide-react';
import { cn } from '@/lib/utils';
interface LoadingSpinnerProps {
size?: 'sm' | 'md' | 'lg';
text?: string;
className?: string;
}
export function LoadingSpinner({ size = 'md', text, className }: LoadingSpinnerProps) {
const sizeClasses = {
sm: 'h-4 w-4',
md: 'h-6 w-6',
lg: 'h-8 w-8'
};
return (
<div className={cn('flex items-center space-x-2', className)}>
<Loader2 className={cn('animate-spin', sizeClasses[size])} />
{text && <span className="text-muted-foreground">{text}</span>}
</div>
);
}
interface PageLoadingProps {
text?: string;
className?: string;
}
export function PageLoading({ text = 'Loading...', className }: PageLoadingProps) {
return (
<div className={cn('flex items-center justify-center min-h-[400px]', className)}>
<LoadingSpinner size="lg" text={text} />
</div>
);
}
interface SkeletonProps {
className?: string;
}
export function Skeleton({ className }: SkeletonProps) {
return (
<div
className={cn(
'animate-pulse rounded-md bg-muted',
className
)}
/>
);
}
export function CardSkeleton() {
return (
<div className="space-y-3">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-4 w-1/2" />
<Skeleton className="h-10 w-full" />
</div>
);
}
export function TableSkeleton({ rows = 5 }: { rows?: number }) {
return (
<div className="space-y-2">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex space-x-4">
<Skeleton className="h-4 w-1/4" />
<Skeleton className="h-4 w-1/4" />
<Skeleton className="h-4 w-1/4" />
<Skeleton className="h-4 w-1/4" />
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SeparatorPrimitive from "@radix-ui/react-separator"
import { cn } from "@/lib/utils"
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = "horizontal", decorative = true, ...props },
ref
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border",
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
className
)}
{...props}
/>
)
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -0,0 +1,29 @@
"use client"
import * as React from "react"
import * as SwitchPrimitives from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitives.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitives.Root
className={cn(
"peer inline-flex h-6 w-11 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=unchecked]:bg-input",
className
)}
{...props}
ref={ref}
>
<SwitchPrimitives.Thumb
className={cn(
"pointer-events-none block h-5 w-5 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitives.Root>
))
Switch.displayName = SwitchPrimitives.Root.displayName
export { Switch }

View File

@@ -0,0 +1,116 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -0,0 +1,55 @@
"use client"
import * as React from "react"
import * as TabsPrimitive from "@radix-ui/react-tabs"
import { cn } from "@/lib/utils"
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
className
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/lib/utils"
export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
{...props}
/>
)
}
)
Textarea.displayName = "Textarea"
export { Textarea }

View File

@@ -0,0 +1,30 @@
import * as React from "react"
import toast from 'react-hot-toast'
interface ToastOptions {
title?: string;
description?: string;
variant?: "default" | "destructive";
}
// Simple wrapper around react-hot-toast to match the expected interface
export function useToast() {
const toastFunction = (options: ToastOptions | string) => {
if (typeof options === 'string') {
return toast(options);
}
const { title, description, variant } = options;
const message = title && description ? `${title}: ${description}` : title || description || '';
if (variant === 'destructive') {
return toast.error(message);
} else {
return toast.success(message);
}
};
return {
toast: toastFunction
}
}

View File

@@ -0,0 +1,201 @@
"use client";
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { usersApi } from '@/lib/api';
import toast from 'react-hot-toast';
import { Loader2 } from 'lucide-react';
interface AddUserDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onUserAdded?: () => void;
}
export default function AddUserDialog({ open, onOpenChange, onUserAdded }: AddUserDialogProps) {
const [loading, setLoading] = useState(false);
const [formData, setFormData] = useState({
email: '',
full_name: '',
password: '',
user_type: 'tenant_user',
tenant_id: '1', // Auto-select test_company tenant for GT AI OS Local
tfa_required: false,
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Validation
if (!formData.email || !formData.full_name || !formData.password) {
toast.error('Please fill in all required fields');
return;
}
if (!formData.password) {
toast.error('Password cannot be empty');
return;
}
// Validate email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(formData.email)) {
toast.error('Please enter a valid email address');
return;
}
// tenant_id is auto-assigned to test_company for GT AI OS Local
setLoading(true);
try {
const payload = {
email: formData.email,
full_name: formData.full_name,
password: formData.password,
user_type: formData.user_type,
tenant_id: formData.tenant_id ? parseInt(formData.tenant_id) : null,
tfa_required: formData.tfa_required,
};
await usersApi.create(payload);
toast.success('User created successfully');
// Reset form
setFormData({
email: '',
full_name: '',
password: '',
user_type: 'tenant_user',
tenant_id: '1', // Auto-select test_company tenant for GT AI OS Local
tfa_required: false,
});
onOpenChange(false);
if (onUserAdded) {
onUserAdded();
}
} catch (error: any) {
console.error('Failed to create user:', error);
const errorMessage = error.response?.data?.detail || 'Failed to create user';
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Add New User</DialogTitle>
<DialogDescription>
Create a new user account. All fields are required.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email *</Label>
<Input
id="email"
type="email"
placeholder="user@example.com"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="full_name">Full Name *</Label>
<Input
id="full_name"
type="text"
placeholder="John Doe"
value={formData.full_name}
onChange={(e) => setFormData({ ...formData, full_name: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Password *</Label>
<Input
id="password"
type="password"
placeholder="Enter password"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
required
/>
<p className="text-xs text-muted-foreground">
Cannot be empty
</p>
</div>
<div className="space-y-2">
<Label htmlFor="user_type">User Type *</Label>
<Select
value={formData.user_type}
onValueChange={(value) => setFormData({ ...formData, user_type: value })}
>
<SelectTrigger>
<SelectValue placeholder="Select user type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="tenant_user">Tenant User</SelectItem>
<SelectItem value="tenant_admin">Tenant Admin</SelectItem>
<SelectItem value="super_admin">Super Admin</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center space-x-2 pt-2">
<input
id="tfa_required"
type="checkbox"
checked={formData.tfa_required}
onChange={(e) => setFormData({ ...formData, tfa_required: e.target.checked })}
className="h-4 w-4 rounded border-gray-300"
/>
<Label htmlFor="tfa_required" className="cursor-pointer font-normal">
Require 2FA for this user
</Label>
</div>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Create User
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,257 @@
"use client";
import { useState, useRef } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Alert } from '@/components/ui/alert';
import { usersApi } from '@/lib/api';
import toast from 'react-hot-toast';
import { Loader2, Upload, Download, FileText, AlertTriangle, CheckCircle, XCircle } from 'lucide-react';
interface BulkUploadDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onUploadComplete?: () => void;
}
interface UploadError {
row: number;
email: string;
reason: string;
}
interface UploadResult {
success_count: number;
failed_count: number;
total_rows: number;
errors: UploadError[];
}
export default function BulkUploadDialog({ open, onOpenChange, onUploadComplete }: BulkUploadDialogProps) {
const [loading, setLoading] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [uploadResult, setUploadResult] = useState<UploadResult | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
if (!selectedFile.name.endsWith('.csv')) {
toast.error('Please select a CSV file');
return;
}
setFile(selectedFile);
setUploadResult(null);
}
};
const handleUpload = async () => {
if (!file) {
toast.error('Please select a file');
return;
}
setLoading(true);
try {
const formData = new FormData();
formData.append('file', file);
const response = await usersApi.bulkUpload(formData);
const result = response.data;
setUploadResult(result);
if (result.failed_count === 0) {
toast.success(`Successfully uploaded ${result.success_count} users`);
if (onUploadComplete) {
onUploadComplete();
}
} else {
toast.error(`Uploaded ${result.success_count} users, ${result.failed_count} failed`);
}
} catch (error: any) {
console.error('Failed to upload users:', error);
const errorMessage = error.response?.data?.detail || 'Failed to upload users';
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
const handleDownloadTemplate = () => {
const csvContent = `email,full_name,password,user_type,tenant_id,tfa_required
john.doe@example.com,John Doe,SecurePass123!,tenant_user,1,false
jane.smith@example.com,Jane Smith,AnotherPass456!,tenant_admin,1,true
admin@example.com,Admin User,AdminPass789!,super_admin,,false`;
const blob = new Blob([csvContent], { type: 'text/csv' });
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'user-upload-template.csv';
a.click();
window.URL.revokeObjectURL(url);
};
const handleClose = () => {
setFile(null);
setUploadResult(null);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
onOpenChange(false);
};
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>Bulk Upload Users</DialogTitle>
<DialogDescription>
Upload a CSV file to create multiple users at once
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Template Download */}
<Alert>
<Download className="h-4 w-4" />
<div className="ml-2">
<p className="font-medium">Need a template?</p>
<p className="text-sm mt-1">
Download the CSV template with example data to get started.
</p>
<Button
variant="link"
size="sm"
onClick={handleDownloadTemplate}
className="p-0 h-auto mt-2"
>
<Download className="h-3 w-3 mr-1" />
Download Template
</Button>
</div>
</Alert>
{/* CSV Format Info */}
<div className="bg-muted p-4 rounded-md space-y-2">
<p className="font-medium text-sm">CSV Format Requirements:</p>
<ul className="text-sm space-y-1 list-disc list-inside">
<li>Required columns: email, full_name, password, user_type</li>
<li>Optional columns: tenant_id (required for tenant_user and tenant_admin), tfa_required</li>
<li>Valid user types: tenant_user, tenant_admin, super_admin</li>
<li>Password cannot be empty</li>
<li>Leave tenant_id blank for super_admin users</li>
<li>tfa_required values: true/false, 1/0, yes/no (default: false)</li>
</ul>
</div>
{/* File Upload */}
<div className="space-y-2">
<label className="text-sm font-medium">Upload CSV File</label>
<div className="flex items-center space-x-2">
<input
ref={fileInputRef}
type="file"
accept=".csv"
onChange={handleFileChange}
className="hidden"
/>
<Button
variant="secondary"
onClick={() => fileInputRef.current?.click()}
disabled={loading}
className="w-full"
>
<Upload className="h-4 w-4 mr-2" />
{file ? file.name : 'Choose CSV File'}
</Button>
</div>
{file && (
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
<FileText className="h-4 w-4" />
<span>{file.name} ({(file.size / 1024).toFixed(2)} KB)</span>
</div>
)}
</div>
{/* Upload Results */}
{uploadResult && (
<div className="space-y-3">
<div className="grid grid-cols-3 gap-4">
<div className="bg-muted p-3 rounded-md">
<div className="text-sm text-muted-foreground">Total Rows</div>
<div className="text-2xl font-bold">{uploadResult.total_rows}</div>
</div>
<div className="bg-green-50 dark:bg-green-950 p-3 rounded-md">
<div className="text-sm text-green-600 dark:text-green-400 flex items-center">
<CheckCircle className="h-3 w-3 mr-1" />
Success
</div>
<div className="text-2xl font-bold text-green-600 dark:text-green-400">
{uploadResult.success_count}
</div>
</div>
<div className="bg-red-50 dark:bg-red-950 p-3 rounded-md">
<div className="text-sm text-red-600 dark:text-red-400 flex items-center">
<XCircle className="h-3 w-3 mr-1" />
Failed
</div>
<div className="text-2xl font-bold text-red-600 dark:text-red-400">
{uploadResult.failed_count}
</div>
</div>
</div>
{/* Error Details */}
{uploadResult.errors.length > 0 && (
<div className="space-y-2">
<div className="flex items-center space-x-2 text-sm font-medium">
<AlertTriangle className="h-4 w-4 text-destructive" />
<span>Errors ({uploadResult.errors.length})</span>
</div>
<div className="max-h-40 overflow-y-auto space-y-2">
{uploadResult.errors.map((error, index) => (
<div key={index} className="bg-destructive/10 p-2 rounded-md text-sm">
<div className="font-medium">Row {error.row}: {error.email}</div>
<div className="text-muted-foreground">{error.reason}</div>
</div>
))}
</div>
</div>
)}
</div>
)}
</div>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={handleClose}
disabled={loading}
>
{uploadResult ? 'Close' : 'Cancel'}
</Button>
{!uploadResult && (
<Button
type="button"
onClick={handleUpload}
disabled={!file || loading}
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Upload Users
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,117 @@
"use client";
import { useState } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Alert } from '@/components/ui/alert';
import { usersApi } from '@/lib/api';
import toast from 'react-hot-toast';
import { Loader2, AlertTriangle } from 'lucide-react';
interface DeleteUserDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
user: {
id: number;
email: string;
full_name: string;
user_type: string;
} | null;
onUserDeleted?: () => void;
}
export default function DeleteUserDialog({ open, onOpenChange, user, onUserDeleted }: DeleteUserDialogProps) {
const [loading, setLoading] = useState(false);
const handleDelete = async () => {
if (!user) return;
setLoading(true);
try {
await usersApi.delete(user.id);
toast.success('User permanently deleted');
onOpenChange(false);
if (onUserDeleted) {
onUserDeleted();
}
} catch (error: any) {
console.error('Failed to delete user:', error);
const errorMessage = error.response?.data?.detail || 'Failed to delete user';
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
if (!user) return null;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle className="flex items-center space-x-2">
<AlertTriangle className="h-5 w-5 text-destructive" />
<span>Permanently Delete User</span>
</DialogTitle>
<DialogDescription>
This action cannot be undone. The user will be permanently deleted from the system.
</DialogDescription>
</DialogHeader>
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<div className="ml-2">
<p className="font-medium">Warning: This is permanent!</p>
<p className="text-sm mt-1">
All user data, including conversations, documents, and settings will be permanently deleted.
This action cannot be undone.
</p>
</div>
</Alert>
<div className="space-y-2 bg-muted p-4 rounded-md">
<div>
<span className="text-sm font-medium">Name:</span>
<span className="ml-2 text-sm">{user.full_name}</span>
</div>
<div>
<span className="text-sm font-medium">Email:</span>
<span className="ml-2 text-sm">{user.email}</span>
</div>
<div>
<span className="text-sm font-medium">User Type:</span>
<span className="ml-2 text-sm capitalize">{user.user_type.replace('_', ' ')}</span>
</div>
</div>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button
type="button"
variant="destructive"
onClick={handleDelete}
disabled={loading}
>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Permanently Delete
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,270 @@
"use client";
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Switch } from '@/components/ui/switch';
import { usersApi } from '@/lib/api';
import toast from 'react-hot-toast';
import { Loader2 } from 'lucide-react';
interface EditUserDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
userId: number | null;
onUserUpdated?: () => void;
}
interface UserData {
id: number;
email: string;
full_name: string;
user_type: string;
tenant_id?: number;
is_active: boolean;
tfa_required?: boolean;
}
export default function EditUserDialog({ open, onOpenChange, userId, onUserUpdated }: EditUserDialogProps) {
const [loading, setLoading] = useState(false);
const [fetchingUser, setFetchingUser] = useState(false);
const [formData, setFormData] = useState({
email: '',
full_name: '',
user_type: 'tenant_user',
tenant_id: '',
is_active: true,
tfa_required: false,
password: '', // Optional - only update if provided
});
const [userData, setUserData] = useState<UserData | null>(null);
// Fetch user data when dialog opens
useEffect(() => {
const fetchData = async () => {
if (!userId || !open) return;
setFetchingUser(true);
try {
// Fetch user data
const userResponse = await usersApi.get(userId);
const user = userResponse.data;
setUserData(user);
// Pre-populate form (tenant_id preserved from user data, not editable in GT AI OS Local)
setFormData({
email: user.email,
full_name: user.full_name,
user_type: user.user_type,
tenant_id: user.tenant_id ? user.tenant_id.toString() : '1',
is_active: user.is_active,
tfa_required: user.tfa_required || false,
password: '',
});
} catch (error) {
console.error('Failed to fetch user data:', error);
toast.error('Failed to load user data');
onOpenChange(false);
} finally {
setFetchingUser(false);
}
};
fetchData();
}, [userId, open, onOpenChange]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!userId) return;
// Validation
if (!formData.email) {
toast.error('Email is required');
return;
}
if (!formData.full_name) {
toast.error('Full name is required');
return;
}
// Password is optional for updates, but if provided it cannot be empty
// (This validation is actually redundant since empty string is falsy)
// tenant_id is auto-assigned/preserved for GT AI OS Local
setLoading(true);
try {
const payload: any = {
email: formData.email,
full_name: formData.full_name,
user_type: formData.user_type,
is_active: formData.is_active,
tfa_required: formData.tfa_required,
tenant_id: formData.tenant_id ? parseInt(formData.tenant_id) : null,
};
// Only include password if provided
if (formData.password) {
payload.password = formData.password;
}
await usersApi.update(userId, payload);
toast.success('User updated successfully');
onOpenChange(false);
if (onUserUpdated) {
onUserUpdated();
}
} catch (error: any) {
console.error('Failed to update user:', error);
const errorMessage = error.response?.data?.detail || 'Failed to update user';
toast.error(errorMessage);
} finally {
setLoading(false);
}
};
if (fetchingUser) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Loading User Data</DialogTitle>
<DialogDescription>
Please wait while we fetch the user information...
</DialogDescription>
</DialogHeader>
<div className="flex items-center justify-center py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
</DialogContent>
</Dialog>
);
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Edit User</DialogTitle>
<DialogDescription>
Update user details. Leave password blank to keep current password.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="email">Email *</Label>
<Input
id="email"
type="email"
placeholder="user@example.com"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="full_name">Full Name *</Label>
<Input
id="full_name"
type="text"
placeholder="John Doe"
value={formData.full_name}
onChange={(e) => setFormData({ ...formData, full_name: e.target.value })}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="password">New Password (Optional)</Label>
<Input
id="password"
type="password"
placeholder="Leave blank to keep current"
value={formData.password}
onChange={(e) => setFormData({ ...formData, password: e.target.value })}
/>
<p className="text-xs text-muted-foreground">
Only fill if you want to change the password
</p>
</div>
<div className="space-y-2">
<Label htmlFor="user_type">User Type *</Label>
<Select
value={formData.user_type}
onValueChange={(value) => setFormData({ ...formData, user_type: value })}
>
<SelectTrigger>
<SelectValue placeholder="Select user type" />
</SelectTrigger>
<SelectContent>
<SelectItem value="tenant_user">Tenant User</SelectItem>
<SelectItem value="tenant_admin">Tenant Admin</SelectItem>
<SelectItem value="super_admin">Super Admin</SelectItem>
</SelectContent>
</Select>
</div>
<div className="flex items-center justify-between space-x-2">
<Label htmlFor="is_active">Active Status</Label>
<Switch
id="is_active"
checked={formData.is_active}
onCheckedChange={(checked) => setFormData({ ...formData, is_active: checked })}
/>
</div>
<div className="flex items-center justify-between space-x-2">
<div>
<Label htmlFor="tfa_required">Require 2FA</Label>
<p className="text-xs text-muted-foreground mt-1">
Force user to setup two-factor authentication
</p>
</div>
<Switch
id="tfa_required"
checked={formData.tfa_required}
onCheckedChange={(checked) => setFormData({ ...formData, tfa_required: checked })}
/>
</div>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => onOpenChange(false)}
disabled={loading}
>
Cancel
</Button>
<Button type="submit" disabled={loading}>
{loading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Update User
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,24 @@
/**
* GT 2.0 Control Panel Session Configuration (NIST SP 800-63B AAL2 Compliant)
*
* Server-authoritative session management (Issue #264).
* The server controls timeout values - these are just for reference/display.
*
* NIST AAL2 Requirements:
* - Idle timeout: 30 minutes (SHALL requirement for inactivity)
* - Absolute timeout: 12 hours (SHALL maximum session duration)
*
* Note: Since polling acts as a heartbeat (resets idle timer), idle timeout
* only triggers when browser is closed. Warning modal is for absolute timeout only.
*/
export const SESSION_CONFIG = {
// How often to poll server for session status (milliseconds)
POLL_INTERVAL_MS: 60 * 1000, // 60 seconds
// Server-controlled values (for reference only - server is authoritative)
// These match the backend SessionService configuration
SERVER_IDLE_TIMEOUT_MINUTES: 30, // 30 minutes - NIST AAL2 requirement
SERVER_ABSOLUTE_TIMEOUT_HOURS: 12, // 12 hours - NIST AAL2 maximum
SERVER_WARNING_THRESHOLD_MINUTES: 30, // Show notice 30 min before absolute timeout
} as const;

View File

@@ -0,0 +1,447 @@
import axios, { AxiosInstance, AxiosError } from 'axios';
import { useAuthStore } from '@/stores/auth-store';
import toast from 'react-hot-toast';
// Determine the correct API URL based on environment
const getApiBaseUrl = () => {
// Always use relative URLs to go through Next.js proxy
// This ensures all requests (SSR and client-side) use the proxy
return '';
};
// Helper function to check if JWT token is expired
const isTokenExpired = (token: string): boolean => {
try {
const payload = JSON.parse(atob(token.split('.')[1]));
if (!payload || !payload.exp) return true;
const now = Math.floor(Date.now() / 1000);
return payload.exp < now;
} catch (error) {
return true;
}
};
// Create axios instance
const api: AxiosInstance = axios.create({
baseURL: getApiBaseUrl(),
timeout: 30000,
headers: {
'Content-Type': 'application/json',
},
});
// Request interceptor to add auth token and check expiry
api.interceptors.request.use(
(config) => {
const token = useAuthStore.getState().token;
// Check if token exists and is expired
if (token && isTokenExpired(token)) {
const { logout } = useAuthStore.getState();
logout();
toast.info('Your session has expired. Please login again.');
window.location.href = '/auth/login';
return Promise.reject(new Error('Token expired'));
}
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => {
return Promise.reject(error);
}
);
/**
* Handle server-side session headers (Issue #264)
* Server is authoritative for session state - dispatch events for IdleTimerProvider
*/
function handleSessionHeaders(response: { headers: Record<string, string> }): void {
// Check for session expired header (401 responses)
const sessionExpired = response.headers?.['x-session-expired'];
if (sessionExpired) {
console.log('[API] Server session expired:', sessionExpired);
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('session-expired', {
detail: { reason: sessionExpired }
}));
}
return; // Don't process warning if already expired
}
// Check for session warning header
const sessionWarning = response.headers?.['x-session-warning'];
if (sessionWarning && sessionWarning !== 'validation-unavailable') {
const secondsRemaining = parseInt(sessionWarning, 10);
if (!isNaN(secondsRemaining) && secondsRemaining > 0) {
console.log('[API] Server session warning:', secondsRemaining, 'seconds remaining');
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('session-warning', {
detail: { secondsRemaining }
}));
}
}
}
}
// Response interceptor for error handling
api.interceptors.response.use(
(response) => {
// Handle session headers on successful responses
handleSessionHeaders(response);
return response;
},
(error: AxiosError) => {
const { response } = error;
// Handle session headers on error responses too
if (response) {
handleSessionHeaders(response as unknown as { headers: Record<string, string> });
}
// Handle authentication errors
if (response?.status === 401) {
// Check if this is a session expiry (let the event handler deal with it)
const sessionExpired = response.headers?.['x-session-expired'];
if (sessionExpired) {
// Event already dispatched by handleSessionHeaders, just reject
return Promise.reject(error);
}
const { logout } = useAuthStore.getState();
logout();
toast.error('Session expired. Please login again.');
window.location.href = '/auth/login';
return Promise.reject(error);
}
// Handle forbidden errors
if (response?.status === 403) {
toast.error('Access denied. Insufficient permissions.');
return Promise.reject(error);
}
// Handle server errors
if (response?.status && response.status >= 500) {
toast.error('Server error. Please try again later.');
return Promise.reject(error);
}
// Handle network errors
if (!response) {
toast.error('Network error. Please check your connection.');
return Promise.reject(error);
}
return Promise.reject(error);
}
);
// API endpoints
export const authApi = {
login: async (email: string, password: string) =>
api.post('/api/v1/login', { email, password }),
logout: async () =>
api.post('/api/v1/logout'),
me: async () =>
api.get('/api/v1/me'),
verifyToken: async () =>
api.get('/api/v1/verify-token'),
changePassword: async (currentPassword: string, newPassword: string) =>
api.post('/api/v1/change-password', {
current_password: currentPassword,
new_password: newPassword,
}),
};
export const tenantsApi = {
list: async (page = 1, limit = 20, search?: string, status?: string) =>
api.get('/api/v1/tenants/', { params: { page, limit, search, status } }),
get: async (id: number) =>
api.get(`/api/v1/tenants/${id}/`),
create: async (data: any) =>
api.post('/api/v1/tenants/', data),
update: async (id: number, data: any) =>
api.put(`/api/v1/tenants/${id}/`, data),
delete: async (id: number) =>
api.delete(`/api/v1/tenants/${id}/`),
deploy: async (id: number) =>
api.post(`/api/v1/tenants/${id}/deploy/`),
suspend: async (id: number) =>
api.post(`/api/v1/tenants/${id}/suspend/`),
activate: async (id: number) =>
api.post(`/api/v1/tenants/${id}/activate/`),
// Optics feature toggle
getOpticsStatus: async (id: number) =>
api.get(`/api/v1/tenants/${id}/optics`),
setOpticsEnabled: async (id: number, enabled: boolean) =>
api.put(`/api/v1/tenants/${id}/optics`, { enabled }),
};
export const usersApi = {
list: async (page = 1, limit = 20, search?: string, tenantId?: number, userType?: string) =>
api.get('/api/v1/users/', { params: { page, limit, search, tenant_id: tenantId, user_type: userType } }),
get: async (id: number) =>
api.get(`/api/v1/users/${id}/`),
create: async (data: any) =>
api.post('/api/v1/users/', data),
update: async (id: number, data: any) =>
api.put(`/api/v1/users/${id}/`, data),
delete: async (id: number) =>
api.delete(`/api/v1/users/${id}/`),
activate: async (id: number) =>
api.post(`/api/v1/users/${id}/activate/`),
deactivate: async (id: number) =>
api.post(`/api/v1/users/${id}/deactivate/`),
bulkUpload: async (formData: FormData) =>
api.post('/api/v1/users/bulk-upload', formData, {
headers: {
'Content-Type': 'multipart/form-data',
},
}),
bulkResetTFA: async (userIds: number[]) =>
api.post('/api/v1/users/bulk/reset-tfa', { user_ids: userIds }),
bulkEnforceTFA: async (userIds: number[]) =>
api.post('/api/v1/users/bulk/enforce-tfa', { user_ids: userIds }),
bulkDisableTFA: async (userIds: number[]) =>
api.post('/api/v1/users/bulk/disable-tfa', { user_ids: userIds }),
};
export const resourcesApi = {
list: async (page = 1, limit = 20) =>
api.get('/api/v1/resources/', { params: { page, limit } }),
get: async (id: number) =>
api.get(`/api/v1/resources/${id}/`),
create: async (data: any) =>
api.post('/api/v1/resources/', data),
update: async (id: number, data: any) =>
api.put(`/api/v1/resources/${id}/`, data),
delete: async (id: number) =>
api.delete(`/api/v1/resources/${id}/`),
testConnection: async (id: number) =>
api.post(`/api/v1/resources/${id}/test/`),
};
export const monitoringApi = {
systemMetrics: async () =>
api.get('/api/v1/monitoring/system/'),
tenantMetrics: async (tenantId?: number) =>
api.get('/api/v1/monitoring/tenants/', { params: { tenant_id: tenantId } }),
usageStats: async (period = '24h', tenantId?: number) =>
api.get('/api/v1/monitoring/usage/', { params: { period, tenant_id: tenantId } }),
alerts: async (page = 1, limit = 20) =>
api.get('/api/v1/monitoring/alerts/', { params: { page, limit } }),
};
export const dashboardApi = {
getMetrics: async () =>
api.get('/api/v1/dashboard/metrics/'),
};
export const systemApi = {
health: async () =>
api.get('/health'),
healthDetailed: async () =>
api.get('/api/v1/system/health-detailed'),
info: async () =>
api.get('/api/v1/system/info/'),
config: async () =>
api.get('/api/v1/system/config/'),
updateConfig: async (data: any) =>
api.put('/api/v1/system/config/', data),
// Software Update Management
version: async () =>
api.get('/api/v1/system/version'),
checkUpdate: async () =>
api.get('/api/v1/system/check-update'),
validateUpdate: async (version: string) =>
api.post('/api/v1/system/validate-update', { target_version: version }),
startUpdate: async (version: string, createBackup = true) =>
api.post('/api/v1/system/update', { target_version: version, create_backup: createBackup }),
getUpdateStatus: async (updateId: string) =>
api.get(`/api/v1/system/update/${updateId}/status`),
rollback: async (updateId: string) =>
api.post(`/api/v1/system/update/${updateId}/rollback`),
// Backup Management
listBackups: async () =>
api.get('/api/v1/system/backups'),
createBackup: async (type = 'full') =>
api.post('/api/v1/system/backups', { backup_type: type }),
getBackup: async (backupId: string) =>
api.get(`/api/v1/system/backups/${backupId}`),
deleteBackup: async (backupId: string) =>
api.delete(`/api/v1/system/backups/${backupId}`),
restoreBackup: async (backupId: string) =>
api.post('/api/v1/system/restore', { backup_id: backupId }),
};
export const assistantLibraryApi = {
listTemplates: async (page = 1, limit = 20, category?: string, status?: string) =>
api.get('/api/v1/resource-management/templates/', { params: { page, limit, category, status } }),
getTemplate: async (id: string) =>
api.get(`/api/v1/resource-management/templates/${id}/`),
createTemplate: async (data: any) =>
api.post('/api/v1/resource-management/templates/', data),
updateTemplate: async (id: string, data: any) =>
api.put(`/api/v1/resource-management/templates/${id}/`, data),
deleteTemplate: async (id: string) =>
api.delete(`/api/v1/resource-management/templates/${id}/`),
deployTemplate: async (templateId: string, tenantIds: string[]) =>
api.post(`/api/v1/resource-management/templates/${templateId}/deploy/`, { tenant_ids: tenantIds }),
getDeployments: async (templateId?: string) =>
api.get('/api/v1/resource-management/deployments/', { params: { template_id: templateId } }),
listAccessGroups: async () =>
api.get('/api/v1/resource-management/access-groups/'),
createAccessGroup: async (data: any) =>
api.post('/api/v1/resource-management/access-groups/', data),
};
export const securityApi = {
getSecurityEvents: async (page = 1, limit = 20, severity?: string, timeRange?: string) =>
api.get('/api/v1/security/events/', { params: { page, limit, severity, time_range: timeRange } }),
getAccessLogs: async (page = 1, limit = 20, timeRange?: string) =>
api.get('/api/v1/security/access-logs/', { params: { page, limit, time_range: timeRange } }),
getSecurityPolicies: async () =>
api.get('/api/v1/security/policies/'),
updateSecurityPolicy: async (id: number, data: any) =>
api.put(`/api/v1/security/policies/${id}/`, data),
getSecurityMetrics: async () =>
api.get('/api/v1/security/metrics/'),
acknowledgeEvent: async (eventId: number) =>
api.post(`/api/v1/security/events/${eventId}/acknowledge/`),
exportSecurityReport: async (timeRange?: string) =>
api.get('/api/v1/security/export-report/', { params: { time_range: timeRange } }),
};
export const tfaApi = {
enable: async () =>
api.post('/api/v1/tfa/enable'),
verifySetup: async (code: string) =>
api.post('/api/v1/tfa/verify-setup', { code }),
disable: async (password: string) =>
api.post('/api/v1/tfa/disable', { password }),
verifyLogin: async (code: string) =>
api.post('/api/v1/tfa/verify-login', { code }, { withCredentials: true }),
getStatus: async () =>
api.get('/api/v1/tfa/status'),
getSessionData: async () =>
api.get('/api/v1/tfa/session-data', { withCredentials: true }),
getQRCodeBlob: async () =>
api.get('/api/v1/tfa/session-qr-code', {
responseType: 'blob',
withCredentials: true,
}),
};
export const apiKeysApi = {
// Get API key status for a tenant (without decryption)
getTenantKeys: async (tenantId: number) =>
api.get(`/api/v1/api-keys/tenant/${tenantId}`),
// Set or update an API key
setKey: async (data: {
tenant_id: number;
provider: string;
api_key: string;
api_secret?: string;
enabled?: boolean;
metadata?: Record<string, unknown>;
}) => api.post('/api/v1/api-keys/set', data),
// Test if an API key is valid
testKey: async (tenantId: number, provider: string) =>
api.post(`/api/v1/api-keys/test/${tenantId}/${provider}`),
// Disable an API key (keeps it but marks disabled)
disableKey: async (tenantId: number, provider: string) =>
api.put(`/api/v1/api-keys/disable/${tenantId}/${provider}`),
// Enable an API key
enableKey: async (tenantId: number, provider: string, apiKey: string) =>
api.post('/api/v1/api-keys/set', {
tenant_id: tenantId,
provider: provider,
api_key: apiKey,
enabled: true,
}),
// Remove an API key completely
removeKey: async (tenantId: number, provider: string) =>
api.delete(`/api/v1/api-keys/remove/${tenantId}/${provider}`),
// Get supported providers list
getProviders: async () =>
api.get('/api/v1/api-keys/providers'),
};
export default api;

View File

@@ -0,0 +1,37 @@
'use client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useState } from 'react';
import { SessionMonitor } from '@/providers/session-monitor';
export function Providers({ children }: { children: React.ReactNode }) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes (renamed from cacheTime in v5)
retry: (failureCount, error: any) => {
// Don't retry on 401 (unauthorized) or 403 (forbidden)
if (error?.response?.status === 401 || error?.response?.status === 403) {
return false;
}
return failureCount < 2;
},
},
mutations: {
retry: false,
},
},
})
);
return (
<QueryClientProvider client={queryClient}>
<SessionMonitor>
{children}
</SessionMonitor>
</QueryClientProvider>
);
}

View File

@@ -0,0 +1,72 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatDate(date: string | Date): string {
return new Intl.DateTimeFormat('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(date))
}
export function formatNumber(num: number): string {
return new Intl.NumberFormat('en-US').format(num)
}
export function formatCurrency(cents: number): string {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(cents / 100)
}
export function formatBytes(bytes: number, decimals: number = 2): string {
if (bytes === 0) return '0 Bytes'
const k = 1024
const dm = decimals < 0 ? 0 : decimals
const sizes = ['Bytes', 'KiB', 'MiB', 'GiB', 'TiB', 'PiB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]
}
export function getStatusColor(status: string): string {
switch (status.toLowerCase()) {
case 'active':
return 'gt-status-active'
case 'pending':
return 'gt-status-pending'
case 'deploying':
return 'gt-status-deploying'
case 'suspended':
case 'terminated':
case 'failed':
return 'gt-status-suspended'
default:
return 'gt-status-pending'
}
}
export function truncate(str: string, length: number): string {
if (str.length <= length) return str
return str.slice(0, length) + '...'
}
export function debounce<T extends (...args: any[]) => any>(
func: T,
wait: number
): (...args: Parameters<T>) => void {
let timeout: NodeJS.Timeout
return (...args: Parameters<T>) => {
clearTimeout(timeout)
timeout = setTimeout(() => func(...args), wait)
}
}

View File

@@ -0,0 +1,188 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { useIdleTimer } from 'react-idle-timer';
import { useAuthStore } from '@/stores/auth-store';
import { SessionTimeoutModal } from '@/components/session/session-timeout-modal';
import { SESSION_CONFIG } from '@/config/session';
import toast from 'react-hot-toast';
interface IdleTimerProviderProps {
children: React.ReactNode;
}
/**
* GT 2.0 Control Panel Idle Timer Provider
*
* OWASP/NIST Compliant Session Management (Issue #264)
*
* This provider implements a hybrid approach:
* - Server-side session tracking is AUTHORITATIVE
* - Client-side IdleTimer provides UX enhancement and backup
*
* Server signals processed:
* - X-Session-Warning: <seconds> - Server says session is about to expire
* - X-Session-Expired: idle|absolute - Server says session has expired
*
* Client-side configuration (secondary to server):
* - 30 minutes total timeout
* - Warning at 25 minutes (5 min before expiry)
* - Multi-tab sync: enabled via crossTab
* - Only active when authenticated
*
* Uses react-idle-timer with promptBeforeIdle pattern
* @see https://idletimer.dev for documentation
* @see SESSION_CONFIG for timeout values
*/
export function IdleTimerProvider({ children }: IdleTimerProviderProps) {
const { isAuthenticated, logout } = useAuthStore();
const [showModal, setShowModal] = useState(false);
const [remainingTime, setRemainingTime] = useState(0);
const [hasHydrated, setHasHydrated] = useState(false);
// Wait for Zustand to hydrate from localStorage
useEffect(() => {
setHasHydrated(true);
}, []);
// Debug logging
useEffect(() => {
console.log('[IdleTimer] Provider mounted, isAuthenticated:', isAuthenticated, 'hasHydrated:', hasHydrated);
console.log('[IdleTimer] Config:', {
timeout: SESSION_CONFIG.TIMEOUT_MS,
promptBeforeIdle: SESSION_CONFIG.PROMPT_BEFORE_IDLE_MS,
});
}, [isAuthenticated, hasHydrated]);
// Listen for server-side session signals (Issue #264)
// The server is the authoritative source of truth for session state
useEffect(() => {
if (typeof window === 'undefined') return;
const handleServerWarning = (event: CustomEvent<{ secondsRemaining: number }>) => {
console.log('[IdleTimer] Server session warning received:', event.detail.secondsRemaining, 'seconds remaining');
// Show the warning modal if not already showing
if (!showModal) {
setShowModal(true);
setRemainingTime(event.detail.secondsRemaining);
}
};
const handleServerExpired = (event: CustomEvent<{ reason: string }>) => {
console.log('[IdleTimer] Server session expired:', event.detail.reason);
setShowModal(false);
// Show appropriate message based on reason
if (event.detail.reason === 'absolute') {
toast.error('Your session has reached the maximum duration. Please log in again.');
} else {
toast.error('Your session has expired due to inactivity. Please log in again.');
}
logout();
window.location.href = '/auth/login';
};
window.addEventListener('session-warning', handleServerWarning as EventListener);
window.addEventListener('session-expired', handleServerExpired as EventListener);
return () => {
window.removeEventListener('session-warning', handleServerWarning as EventListener);
window.removeEventListener('session-expired', handleServerExpired as EventListener);
};
}, [showModal, logout]);
const handleOnPrompt = useCallback(() => {
console.log('[IdleTimer] Session expiring soon - showing warning modal');
setShowModal(true);
// Initial remaining time in seconds
setRemainingTime(Math.floor(SESSION_CONFIG.PROMPT_BEFORE_IDLE_MS / 1000));
}, []);
const handleOnIdle = useCallback(() => {
console.log('[IdleTimer] Session expired due to inactivity - logging out');
setShowModal(false);
toast.error('Your session has expired due to inactivity. Please log in again.');
logout();
window.location.href = '/auth/login';
}, [logout]);
const handleOnActive = useCallback(() => {
// Note: onActive only fires when activate() is called while isPrompted
// Not when user moves mouse during countdown
console.log('[IdleTimer] Session reactivated');
setShowModal(false);
}, []);
const { activate, getRemainingTime, isPrompted } = useIdleTimer({
// Total inactivity timeout (see SESSION_CONFIG for values)
timeout: SESSION_CONFIG.TIMEOUT_MS,
// Show prompt before timeout (see SESSION_CONFIG for values)
promptBeforeIdle: SESSION_CONFIG.PROMPT_BEFORE_IDLE_MS,
// Event handlers
onPrompt: handleOnPrompt,
onIdle: handleOnIdle,
onActive: handleOnActive,
// Events to track (react-idle-timer defaults + focus)
events: SESSION_CONFIG.EVENTS as unknown as string[],
// Throttle event processing for performance
eventsThrottle: SESSION_CONFIG.EVENTS_THROTTLE_MS,
// Multi-tab synchronization via BroadcastChannel
crossTab: true,
syncTimers: 200,
// Only run when authenticated AND after Zustand has hydrated from localStorage
disabled: !hasHydrated || !isAuthenticated,
// Start automatically when mounted
startOnMount: true,
});
// Update countdown every second when modal is shown
useEffect(() => {
let interval: NodeJS.Timeout | null = null;
if (showModal && isPrompted()) {
interval = setInterval(() => {
const remaining = Math.ceil(getRemainingTime() / 1000);
setRemainingTime(remaining);
// Safety check - if remaining hits 0, ensure we log out
if (remaining <= 0) {
setShowModal(false);
logout();
window.location.href = '/auth/login';
}
}, 1000);
}
return () => {
if (interval) {
clearInterval(interval);
}
};
}, [showModal, isPrompted, getRemainingTime, logout]);
const handleExtendSession = useCallback(() => {
console.log('[IdleTimer] User extended session');
// Reset the idle timer and hide modal
activate();
setShowModal(false);
toast.success('Session extended');
}, [activate]);
const handleLogoutNow = useCallback(() => {
console.log('[IdleTimer] User clicked logout from warning modal');
setShowModal(false);
logout();
window.location.href = '/auth/login';
}, [logout]);
return (
<>
{children}
<SessionTimeoutModal
open={showModal}
remainingTime={remainingTime}
onExtendSession={handleExtendSession}
onLogout={handleLogoutNow}
/>
</>
);
}

View File

@@ -0,0 +1,167 @@
'use client';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useAuthStore } from '@/stores/auth-store';
import { SessionTimeoutModal } from '@/components/session/session-timeout-modal';
import toast from 'react-hot-toast';
interface SessionMonitorProps {
children: React.ReactNode;
}
// Polling interval: 60 seconds
const POLL_INTERVAL_MS = 60 * 1000;
/**
* GT 2.0 Control Panel Session Monitor (NIST SP 800-63B AAL2 Compliant)
*
* Server-authoritative session management with two timeout types:
* - Idle timeout (30 min): Resets with activity - polling acts as heartbeat
* - Absolute timeout (12 hr): Cannot be extended - forces re-authentication
*
* How it works:
* 1. Polls /api/v1/session/status every 60 seconds
* 2. Polling resets idle timeout, so active users won't hit idle limit
* 3. When absolute timeout < 30 min remaining, show informational notice
* 4. User acknowledges notice (can't extend absolute timeout)
* 5. If is_valid=false, force logout
*/
export function SessionMonitor({ children }: SessionMonitorProps) {
const { isAuthenticated, logout, token } = useAuthStore();
const [showModal, setShowModal] = useState(false);
const [remainingTime, setRemainingTime] = useState(0);
const [hasHydrated, setHasHydrated] = useState(false);
const [hasAcknowledged, setHasAcknowledged] = useState(false);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const countdownRef = useRef<NodeJS.Timeout | null>(null);
// Wait for Zustand to hydrate from localStorage
useEffect(() => {
setHasHydrated(true);
}, []);
// Check session status from server
const checkSessionStatus = useCallback(async () => {
if (!isAuthenticated || !token) return;
try {
const response = await fetch('/api/v1/session/status', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (response.status === 401 || response.status === 503) {
// Session expired or service unavailable - logout
console.log('[SessionMonitor] Session invalid, logging out');
toast.error('Your session has expired. Please log in again.');
logout();
window.location.href = '/auth/login';
return;
}
if (!response.ok) {
console.warn('[SessionMonitor] Session check failed:', response.status);
return;
}
const data = await response.json();
if (!data.is_valid) {
// Session expired
console.log('[SessionMonitor] Server says session invalid');
toast.error('Your session has expired. Please log in again.');
logout();
window.location.href = '/auth/login';
return;
}
// Update remaining time (use absolute timeout for display)
if (data.absolute_seconds_remaining !== null) {
setRemainingTime(data.absolute_seconds_remaining);
}
// Show warning if needed (and user hasn't already acknowledged)
if (data.show_warning && !showModal && !hasAcknowledged) {
console.log('[SessionMonitor] Absolute timeout approaching, showing notice');
setShowModal(true);
}
} catch (error) {
console.error('[SessionMonitor] Error checking session:', error);
// On network error, don't logout immediately - wait for next poll
}
}, [isAuthenticated, token, showModal, hasAcknowledged, logout]);
// Start/stop polling based on auth state
useEffect(() => {
if (!hasHydrated || !isAuthenticated) {
// Clear any existing interval and reset state
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
setHasAcknowledged(false); // Reset on logout
return;
}
// Initial check
checkSessionStatus();
// Start polling
intervalRef.current = setInterval(checkSessionStatus, POLL_INTERVAL_MS);
return () => {
if (intervalRef.current) {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
};
}, [hasHydrated, isAuthenticated, checkSessionStatus]);
// Countdown timer when modal is shown
useEffect(() => {
if (showModal && remainingTime > 0) {
countdownRef.current = setInterval(() => {
setRemainingTime(prev => {
const newTime = prev - 1;
if (newTime <= 0) {
// Time's up - logout
setShowModal(false);
toast.error('Your session has expired. Please log in again.');
logout();
window.location.href = '/auth/login';
return 0;
}
return newTime;
});
}, 1000);
}
return () => {
if (countdownRef.current) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
};
}, [showModal, logout]);
// Handle "I Understand" button - just dismiss the notice
const handleAcknowledge = useCallback(() => {
console.log('[SessionMonitor] User acknowledged session expiration notice');
setShowModal(false);
setHasAcknowledged(true);
}, []);
return (
<>
{children}
<SessionTimeoutModal
open={showModal}
remainingTime={remainingTime}
onAcknowledge={handleAcknowledge}
/>
</>
);
}

View File

@@ -0,0 +1,258 @@
/**
* Two-Factor Authentication Service
*
* Handles all TFA-related API calls to the Control Panel backend.
*/
import {
TFAEnableResponse,
TFAVerifySetupResponse,
TFADisableResponse,
TFAVerifyLoginResponse,
TFAStatusResponse,
TFASessionData,
} from '@/types/tfa';
// Get API URL from environment or use relative path for Next.js proxy
const getApiUrl = () => {
if (typeof window === 'undefined') {
// Server-side: use Docker hostname (INTERNAL_API_URL) or fallback to Docker DNS
return process.env.INTERNAL_API_URL || 'http://control-panel-backend:8000';
}
// Client-side: use relative path (goes through Next.js proxy)
return '';
};
const API_URL = getApiUrl();
/**
* Get auth token from localStorage
*/
function getAuthToken(): string | null {
if (typeof window === 'undefined') return null;
try {
const authStorage = localStorage.getItem('gt2-auth-storage');
if (!authStorage) return null;
const parsed = JSON.parse(authStorage);
return parsed.state?.token || null;
} catch {
return null;
}
}
/**
* Get TFA session data (metadata only - no QR code) using HTTP-only session cookie
* Called from /verify-tfa page to display setup information
*/
export async function getTFASessionData(): Promise<TFASessionData> {
try {
const response = await fetch(`${API_URL}/api/v1/tfa/session-data`, {
method: 'GET',
credentials: 'include', // Include HTTP-only cookies
headers: {
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to get TFA session data');
}
return await response.json();
} catch (error) {
console.error('Get TFA session data error:', error);
throw error;
}
}
/**
* Get TFA QR code as PNG blob (secure: TOTP secret never exposed to JavaScript)
* Called from /verify-tfa page to display QR code via blob URL
*/
export async function getTFAQRCodeBlob(): Promise<string> {
try {
const response = await fetch(`${API_URL}/api/v1/tfa/session-qr-code`, {
method: 'GET',
credentials: 'include', // Include HTTP-only cookies
});
if (!response.ok) {
throw new Error('Failed to get TFA QR code');
}
// Get PNG blob
const blob = await response.blob();
// Create object URL (will be revoked on unmount)
const blobUrl = URL.createObjectURL(blob);
return blobUrl;
} catch (error) {
console.error('Get TFA QR code error:', error);
throw error;
}
}
/**
* Enable TFA for current user (user-initiated from settings)
*/
export async function enableTFA(): Promise<TFAEnableResponse> {
const token = getAuthToken();
if (!token) {
throw new Error('Not authenticated');
}
try {
const response = await fetch(`${API_URL}/api/v1/tfa/enable`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to enable TFA');
}
return await response.json();
} catch (error) {
console.error('Enable TFA error:', error);
throw error;
}
}
/**
* Verify TFA setup code and complete setup
*/
export async function verifyTFASetup(code: string): Promise<TFAVerifySetupResponse> {
const token = getAuthToken();
if (!token) {
throw new Error('Not authenticated');
}
try {
const response = await fetch(`${API_URL}/api/v1/tfa/verify-setup`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ code }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Invalid verification code');
}
return await response.json();
} catch (error) {
console.error('Verify TFA setup error:', error);
throw error;
}
}
/**
* Disable TFA for current user (requires password)
*/
export async function disableTFA(password: string): Promise<TFADisableResponse> {
const token = getAuthToken();
if (!token) {
throw new Error('Not authenticated');
}
try {
const response = await fetch(`${API_URL}/api/v1/tfa/disable`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ password }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Failed to disable TFA');
}
return await response.json();
} catch (error) {
console.error('Disable TFA error:', error);
throw error;
}
}
/**
* Verify TFA code during login
* Uses HTTP-only session cookie for authentication
*/
export async function verifyTFALogin(code: string): Promise<TFAVerifyLoginResponse> {
try {
const response = await fetch(`${API_URL}/api/v1/tfa/verify-login`, {
method: 'POST',
credentials: 'include', // Include HTTP-only cookies
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ code }),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.detail || 'Invalid verification code');
}
return await response.json();
} catch (error) {
console.error('Verify TFA login error:', error);
throw error;
}
}
/**
* Get TFA status for current user
*/
export async function getTFAStatus(): Promise<TFAStatusResponse> {
const token = getAuthToken();
if (!token) {
throw new Error('Not authenticated');
}
try {
const response = await fetch(`${API_URL}/api/v1/tfa/status`, {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (!response.ok) {
// Return default if request fails
return {
tfa_enabled: false,
tfa_required: false,
tfa_status: 'disabled',
};
}
return await response.json();
} catch (error) {
console.error('Get TFA status error:', error);
return {
tfa_enabled: false,
tfa_required: false,
tfa_status: 'disabled',
};
}
}

View File

@@ -0,0 +1,361 @@
/**
* Unit tests for authentication store
*/
import { renderHook, act } from '@testing-library/react'
import { useAuthStore } from '../auth-store'
// Mock axios
jest.mock('axios', () => ({
create: () => ({
post: jest.fn(),
get: jest.fn(),
interceptors: {
request: { use: jest.fn() },
response: { use: jest.fn() }
}
})
}))
// Mock toast
jest.mock('react-hot-toast', () => ({
success: jest.fn(),
error: jest.fn()
}))
// Mock API
const mockAuthApi = {
login: jest.fn(),
logout: jest.fn(),
me: jest.fn(),
changePassword: jest.fn()
}
jest.mock('@/lib/api', () => ({
authApi: mockAuthApi
}))
describe('Auth Store', () => {
beforeEach(() => {
// Reset store state
useAuthStore.setState({
user: null,
token: null,
isLoading: false,
isAuthenticated: false
})
// Reset mocks
jest.clearAllMocks()
// Clear localStorage
localStorage.clear()
})
describe('Initial State', () => {
test('has correct initial state', () => {
const { result } = renderHook(() => useAuthStore())
expect(result.current.user).toBeNull()
expect(result.current.token).toBeNull()
expect(result.current.isLoading).toBe(false)
expect(result.current.isAuthenticated).toBe(false)
})
})
describe('Login', () => {
test('successful login updates state correctly', async () => {
const mockUser = {
id: 1,
email: 'test@example.com',
full_name: 'Test User',
user_type: 'super_admin'
}
const mockToken = 'mock-jwt-token'
mockAuthApi.login.mockResolvedValueOnce({
data: {
access_token: mockToken,
user: mockUser
}
})
const { result } = renderHook(() => useAuthStore())
let loginResult: boolean
await act(async () => {
loginResult = await result.current.login('test@example.com', 'password123')
})
expect(loginResult!).toBe(true)
expect(result.current.user).toEqual(mockUser)
expect(result.current.token).toBe(mockToken)
expect(result.current.isAuthenticated).toBe(true)
expect(result.current.isLoading).toBe(false)
expect(mockAuthApi.login).toHaveBeenCalledWith('test@example.com', 'password123')
})
test('failed login handles error correctly', async () => {
const mockError = {
response: {
data: {
error: {
message: 'Invalid credentials'
}
}
}
}
mockAuthApi.login.mockRejectedValueOnce(mockError)
const { result } = renderHook(() => useAuthStore())
let loginResult: boolean
await act(async () => {
loginResult = await result.current.login('test@example.com', 'wrongpassword')
})
expect(loginResult!).toBe(false)
expect(result.current.user).toBeNull()
expect(result.current.token).toBeNull()
expect(result.current.isAuthenticated).toBe(false)
expect(result.current.isLoading).toBe(false)
})
test('login sets loading state correctly', async () => {
mockAuthApi.login.mockImplementationOnce(() =>
new Promise(resolve => setTimeout(resolve, 100))
)
const { result } = renderHook(() => useAuthStore())
act(() => {
result.current.login('test@example.com', 'password123')
})
expect(result.current.isLoading).toBe(true)
})
})
describe('Logout', () => {
test('logout clears state correctly', async () => {
// Set initial authenticated state
const mockUser = {
id: 1,
email: 'test@example.com',
full_name: 'Test User',
user_type: 'super_admin'
}
useAuthStore.setState({
user: mockUser,
token: 'mock-token',
isAuthenticated: true
})
const { result } = renderHook(() => useAuthStore())
await act(async () => {
result.current.logout()
})
expect(result.current.user).toBeNull()
expect(result.current.token).toBeNull()
expect(result.current.isAuthenticated).toBe(false)
expect(result.current.isLoading).toBe(false)
})
test('logout calls API endpoint', async () => {
const { result } = renderHook(() => useAuthStore())
await act(async () => {
result.current.logout()
})
expect(mockAuthApi.logout).toHaveBeenCalled()
})
})
describe('Check Auth', () => {
test('checkAuth validates existing token', async () => {
const mockUser = {
id: 1,
email: 'test@example.com',
full_name: 'Test User',
user_type: 'super_admin'
}
// Set token in store
useAuthStore.setState({ token: 'valid-token' })
mockAuthApi.me.mockResolvedValueOnce({
data: {
data: mockUser
}
})
const { result } = renderHook(() => useAuthStore())
await act(async () => {
await result.current.checkAuth()
})
expect(result.current.user).toEqual(mockUser)
expect(result.current.isAuthenticated).toBe(true)
expect(result.current.isLoading).toBe(false)
expect(mockAuthApi.me).toHaveBeenCalled()
})
test('checkAuth handles invalid token', async () => {
// Set invalid token in store
useAuthStore.setState({ token: 'invalid-token' })
mockAuthApi.me.mockRejectedValueOnce(new Error('Unauthorized'))
const { result } = renderHook(() => useAuthStore())
await act(async () => {
await result.current.checkAuth()
})
expect(result.current.user).toBeNull()
expect(result.current.token).toBeNull()
expect(result.current.isAuthenticated).toBe(false)
expect(result.current.isLoading).toBe(false)
})
test('checkAuth handles no token', async () => {
const { result } = renderHook(() => useAuthStore())
await act(async () => {
await result.current.checkAuth()
})
expect(result.current.isLoading).toBe(false)
expect(result.current.isAuthenticated).toBe(false)
expect(mockAuthApi.me).not.toHaveBeenCalled()
})
})
describe('Update User', () => {
test('updateUser merges user data correctly', () => {
const initialUser = {
id: 1,
email: 'test@example.com',
full_name: 'Test User',
user_type: 'super_admin'
}
useAuthStore.setState({ user: initialUser })
const { result } = renderHook(() => useAuthStore())
act(() => {
result.current.updateUser({ full_name: 'Updated Name' })
})
expect(result.current.user).toEqual({
...initialUser,
full_name: 'Updated Name'
})
})
test('updateUser handles no existing user', () => {
const { result } = renderHook(() => useAuthStore())
act(() => {
result.current.updateUser({ full_name: 'New Name' })
})
expect(result.current.user).toBeNull()
})
})
describe('Change Password', () => {
test('successful password change returns true', async () => {
mockAuthApi.changePassword.mockResolvedValueOnce({})
const { result } = renderHook(() => useAuthStore())
let changeResult: boolean
await act(async () => {
changeResult = await result.current.changePassword('oldpass', 'newpass')
})
expect(changeResult!).toBe(true)
expect(mockAuthApi.changePassword).toHaveBeenCalledWith('oldpass', 'newpass')
})
test('failed password change returns false', async () => {
const mockError = {
response: {
data: {
error: {
message: 'Current password is incorrect'
}
}
}
}
mockAuthApi.changePassword.mockRejectedValueOnce(mockError)
const { result } = renderHook(() => useAuthStore())
let changeResult: boolean
await act(async () => {
changeResult = await result.current.changePassword('wrongpass', 'newpass')
})
expect(changeResult!).toBe(false)
})
})
describe('Persistence', () => {
test('persists authentication state to localStorage', async () => {
const mockUser = {
id: 1,
email: 'test@example.com',
full_name: 'Test User',
user_type: 'super_admin'
}
const mockToken = 'mock-jwt-token'
mockAuthApi.login.mockResolvedValueOnce({
data: {
access_token: mockToken,
user: mockUser
}
})
const { result } = renderHook(() => useAuthStore())
await act(async () => {
await result.current.login('test@example.com', 'password123')
})
// Check localStorage was called
expect(localStorage.setItem).toHaveBeenCalled()
})
test('restores authentication state from localStorage', () => {
const mockStoredState = {
user: {
id: 1,
email: 'test@example.com',
full_name: 'Test User',
user_type: 'super_admin'
},
token: 'stored-token',
isAuthenticated: true
}
localStorage.setItem('gt2-auth-storage', JSON.stringify(mockStoredState))
// Create new store instance to test persistence
const { result } = renderHook(() => useAuthStore())
// Note: In actual implementation, the persistence would restore automatically
// This test would need to be adjusted based on how Zustand persistence works
expect(result.current.user).toBeDefined()
})
})
})

View File

@@ -0,0 +1,221 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
import { authApi } from '@/lib/api';
import toast from 'react-hot-toast';
// Local User type definition
interface User {
id: number;
email: string;
full_name: string;
user_type: string;
tenant_id?: number;
capabilities?: any[];
created_at?: string;
updated_at?: string;
tfa_setup_pending?: boolean;
}
interface AuthState {
user: User | null;
token: string | null;
isLoading: boolean;
isAuthenticated: boolean;
requiresTfa: boolean;
tfaConfigured: boolean;
}
interface AuthActions {
login: (email: string, password: string) => Promise<{ success: boolean; requiresTfa?: boolean }>;
logout: () => void;
checkAuth: () => Promise<void>;
updateUser: (user: Partial<User>) => void;
changePassword: (currentPassword: string, newPassword: string) => Promise<boolean>;
completeTfaLogin: (token: string, user: User) => void;
}
export const useAuthStore = create<AuthState & AuthActions>()(
persist(
(set, get) => ({
// State
user: null,
token: null,
isLoading: false,
isAuthenticated: false,
requiresTfa: false,
tfaConfigured: false,
// Actions
login: async (email: string, password: string) => {
try {
set({ isLoading: true });
const response = await authApi.login(email, password);
console.log('Login response:', response.data);
const data = response.data;
// Check if TFA is required
if (data.requires_tfa === true) {
console.log('TFA required, configured:', data.tfa_configured);
// Validate super_admin role BEFORE allowing TFA flow
if (data.user_type !== 'super_admin') {
set({ isLoading: false });
toast.error('Control Panel access requires super admin privileges');
return { success: false, requiresTfa: false };
}
set({
requiresTfa: true,
tfaConfigured: data.tfa_configured,
isLoading: false,
isAuthenticated: false,
});
return { success: true, requiresTfa: true };
}
// Normal login without TFA
const { access_token, user } = data;
// Validate super_admin role for Control Panel access
if (user.user_type !== 'super_admin') {
set({ isLoading: false });
toast.error('Control Panel access requires super admin privileges');
return { success: false, requiresTfa: false };
}
set({
user,
token: access_token,
isAuthenticated: true,
isLoading: false,
requiresTfa: false,
tfaConfigured: false,
});
toast.success(`Welcome back, ${user.full_name}!`);
return { success: true, requiresTfa: false };
} catch (error: any) {
console.error('Login error:', error);
set({ isLoading: false });
const message = error.response?.data?.error?.message || 'Login failed';
toast.error(message);
return { success: false };
}
},
completeTfaLogin: (token: string, user: User) => {
// Validate super_admin role for Control Panel access
if (user.user_type !== 'super_admin') {
set({ isLoading: false });
toast.error('Control Panel access requires super admin privileges');
return;
}
set({
user,
token,
isAuthenticated: true,
requiresTfa: false,
tfaConfigured: false,
isLoading: false,
});
toast.success(`Welcome back, ${user.full_name}!`);
},
logout: () => {
try {
// Call logout endpoint (fire and forget)
authApi.logout().catch(() => {
// Ignore errors on logout
});
} catch {
// Ignore errors
}
set({
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
requiresTfa: false,
tfaConfigured: false,
});
toast.success('Logged out successfully');
},
checkAuth: async () => {
const { token } = get();
console.log('CheckAuth called with token:', token ? 'exists' : 'none');
if (!token) {
console.log('No token, setting unauthenticated');
set({ isLoading: false, isAuthenticated: false });
return;
}
try {
set({ isLoading: true });
const response = await authApi.me();
console.log('Me API response:', response.data);
const user = response.data.data;
console.log('Extracted user from /me:', user);
set({
user,
isAuthenticated: true,
isLoading: false,
});
} catch (error) {
console.error('CheckAuth failed:', error);
// Token is invalid, clear auth state
set({
user: null,
token: null,
isAuthenticated: false,
isLoading: false,
});
}
},
updateUser: (userData: Partial<User>) => {
const { user } = get();
if (user) {
set({
user: { ...user, ...userData }
});
}
},
changePassword: async (currentPassword: string, newPassword: string) => {
try {
await authApi.changePassword(currentPassword, newPassword);
toast.success('Password changed successfully');
return true;
} catch (error: any) {
const message = error.response?.data?.error?.message || 'Password change failed';
toast.error(message);
return false;
}
},
}),
{
name: 'gt2-auth-storage',
partialize: (state) => ({
user: state.user,
token: state.token,
isAuthenticated: state.isAuthenticated,
requiresTfa: state.requiresTfa,
tfaConfigured: state.tfaConfigured,
}),
}
)
);

View File

@@ -0,0 +1,100 @@
/**
* Two-Factor Authentication Type Definitions
*
* Shared types for TFA functionality across the Control Panel
*/
export type TFAStatus = 'disabled' | 'enabled' | 'enforced';
export interface TFAEnableResponse {
success: boolean;
message: string;
qr_code_uri: string;
manual_entry_key: string;
}
export interface TFAVerifySetupRequest {
code: string;
}
export interface TFAVerifySetupResponse {
success: boolean;
message: string;
}
export interface TFADisableRequest {
password: string;
}
export interface TFADisableResponse {
success: boolean;
message: string;
}
export interface TFAVerifyLoginRequest {
code: string;
}
export interface TFAVerifyLoginResponse {
success: boolean;
access_token?: string;
message?: string;
user?: any;
expires_in?: number;
}
export interface TFAStatusResponse {
tfa_enabled: boolean;
tfa_required: boolean;
tfa_status: TFAStatus;
}
export interface TFASessionData {
user_email: string;
tfa_configured: boolean;
qr_code_uri?: string;
manual_entry_key?: string;
}
// Login response types for TFA flow detection
export interface NormalLoginResponse {
access_token: string;
token_type: string;
expires_in: number;
user: any;
}
export interface TFASetupResponse {
requires_tfa: true;
tfa_configured: false;
temp_token: string;
qr_code_uri: string;
manual_entry_key: string;
user_email: string;
}
export interface TFAVerificationResponse {
requires_tfa: true;
tfa_configured: true;
temp_token: string;
user_email: string;
}
export type LoginResponse = NormalLoginResponse | TFASetupResponse | TFAVerificationResponse;
// Type guards for login response detection
export function isTFAResponse(response: any): response is TFASetupResponse | TFAVerificationResponse {
return response && response.requires_tfa === true;
}
export function isTFASetupResponse(response: any): response is TFASetupResponse {
return response && response.requires_tfa === true && response.tfa_configured === false;
}
export function isTFAVerificationResponse(response: any): response is TFAVerificationResponse {
return response && response.requires_tfa === true && response.tfa_configured === true;
}
export function isNormalLoginResponse(response: any): response is NormalLoginResponse {
return response && response.access_token && !response.requires_tfa;
}

View File

@@ -0,0 +1,76 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: ["class"],
content: [
'./pages/**/*.{ts,tsx}',
'./components/**/*.{ts,tsx}',
'./app/**/*.{ts,tsx}',
'./src/**/*.{ts,tsx}',
],
theme: {
container: {
center: true,
padding: "2rem",
screens: {
"2xl": "1400px",
},
},
extend: {
colors: {
border: "hsl(var(--border))",
input: "hsl(var(--input))",
ring: "hsl(var(--ring))",
background: "hsl(var(--background))",
foreground: "hsl(var(--foreground))",
primary: {
DEFAULT: "hsl(var(--primary))",
foreground: "hsl(var(--primary-foreground))",
},
secondary: {
DEFAULT: "hsl(var(--secondary))",
foreground: "hsl(var(--secondary-foreground))",
},
destructive: {
DEFAULT: "hsl(var(--destructive))",
foreground: "hsl(var(--destructive-foreground))",
},
muted: {
DEFAULT: "hsl(var(--muted))",
foreground: "hsl(var(--muted-foreground))",
},
accent: {
DEFAULT: "hsl(var(--accent))",
foreground: "hsl(var(--accent-foreground))",
},
popover: {
DEFAULT: "hsl(var(--popover))",
foreground: "hsl(var(--popover-foreground))",
},
card: {
DEFAULT: "hsl(var(--card))",
foreground: "hsl(var(--card-foreground))",
},
},
borderRadius: {
lg: "var(--radius)",
md: "calc(var(--radius) - 2px)",
sm: "calc(var(--radius) - 4px)",
},
keyframes: {
"accordion-down": {
from: { height: 0 },
to: { height: "var(--radix-accordion-content-height)" },
},
"accordion-up": {
from: { height: "var(--radix-accordion-content-height)" },
to: { height: 0 },
},
},
animation: {
"accordion-down": "accordion-down 0.2s ease-out",
"accordion-up": "accordion-up 0.2s ease-out",
},
},
},
plugins: [require("tailwindcss-animate")],
}

View File

@@ -0,0 +1,32 @@
{
"compilerOptions": {
"target": "es5",
"lib": ["dom", "dom.iterable", "es6"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"],
"@/components/*": ["./src/components/*"],
"@/lib/*": ["./src/lib/*"],
"@/hooks/*": ["./src/hooks/*"],
"@/stores/*": ["./src/stores/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}