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:
3
apps/control-panel-frontend/.eslintrc.json
Normal file
3
apps/control-panel-frontend/.eslintrc.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"extends": ["next/core-web-vitals"]
|
||||
}
|
||||
62
apps/control-panel-frontend/Dockerfile
Normal file
62
apps/control-panel-frontend/Dockerfile
Normal 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"]
|
||||
35
apps/control-panel-frontend/Dockerfile.dev
Normal file
35
apps/control-panel-frontend/Dockerfile.dev
Normal 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"]
|
||||
57
apps/control-panel-frontend/Dockerfile.prod
Normal file
57
apps/control-panel-frontend/Dockerfile.prod
Normal 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"]
|
||||
45
apps/control-panel-frontend/jest.config.js
Normal file
45
apps/control-panel-frontend/jest.config.js
Normal 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)
|
||||
117
apps/control-panel-frontend/jest.setup.js
Normal file
117
apps/control-panel-frontend/jest.setup.js
Normal 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
|
||||
})
|
||||
60
apps/control-panel-frontend/next.config.js
Normal file
60
apps/control-panel-frontend/next.config.js
Normal 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
10337
apps/control-panel-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
70
apps/control-panel-frontend/package.json
Normal file
70
apps/control-panel-frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
apps/control-panel-frontend/postcss.config.js
Normal file
6
apps/control-panel-frontend/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
146
apps/control-panel-frontend/src/app/auth/login/page.tsx
Normal file
146
apps/control-panel-frontend/src/app/auth/login/page.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import * as z from 'zod';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Loader2, Eye, EyeOff } from 'lucide-react';
|
||||
|
||||
const loginSchema = z.object({
|
||||
email: z.string().email('Invalid email address'),
|
||||
password: z.string().min(1, 'Password is required'),
|
||||
});
|
||||
|
||||
type LoginForm = z.infer<typeof loginSchema>;
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const { login, isLoading, isAuthenticated } = useAuthStore();
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
|
||||
const {
|
||||
register,
|
||||
handleSubmit,
|
||||
formState: { errors, isSubmitting },
|
||||
} = useForm<LoginForm>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
});
|
||||
|
||||
// Redirect if already authenticated
|
||||
useEffect(() => {
|
||||
if (isAuthenticated) {
|
||||
router.replace('/dashboard/tenants');
|
||||
}
|
||||
}, [isAuthenticated, router]);
|
||||
|
||||
const onSubmit = async (data: LoginForm) => {
|
||||
const result = await login(data.email, data.password);
|
||||
if (result.success) {
|
||||
if (result.requiresTfa) {
|
||||
// Redirect to TFA verification page
|
||||
router.push('/auth/verify-tfa');
|
||||
} else {
|
||||
// Check if user has TFA setup pending
|
||||
const { user } = useAuthStore.getState();
|
||||
if (user?.tfa_setup_pending) {
|
||||
router.push('/dashboard/settings');
|
||||
} else {
|
||||
router.push('/dashboard/tenants');
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
if (isAuthenticated) {
|
||||
return null; // Prevent flash before redirect
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1 text-center">
|
||||
<div className="w-16 h-16 bg-primary rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<span className="text-2xl font-bold text-primary-foreground">GT</span>
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold">GT 2.0 Control Panel</CardTitle>
|
||||
<CardDescription>
|
||||
Sign in to your super administrator account
|
||||
</CardDescription>
|
||||
<div className="mt-4 p-3 bg-amber-50 dark:bg-amber-950 border border-amber-200 dark:border-amber-800 rounded-md">
|
||||
<p className="text-xs text-amber-800 dark:text-amber-200 text-center">
|
||||
<strong>Super Admin Access Only</strong>
|
||||
<br />
|
||||
Only super administrators can access the Control Panel.
|
||||
Tenant admins and users should use the main tenant application.
|
||||
</p>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="Enter your email"
|
||||
{...register('email')}
|
||||
className={errors.email ? 'border-red-500' : ''}
|
||||
/>
|
||||
{errors.email && (
|
||||
<p className="text-sm text-red-600">{errors.email.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="Enter your password"
|
||||
{...register('password')}
|
||||
className={errors.password ? 'border-red-500 pr-10' : 'pr-10'}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4 text-gray-400" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors.password && (
|
||||
<p className="text-sm text-red-600">{errors.password.message}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={isSubmitting || isLoading}
|
||||
>
|
||||
{isSubmitting || isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
'Sign In'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
293
apps/control-panel-frontend/src/app/auth/verify-tfa/page.tsx
Normal file
293
apps/control-panel-frontend/src/app/auth/verify-tfa/page.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { verifyTFALogin, getTFASessionData, getTFAQRCodeBlob } from '@/services/tfa';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Loader2, Lock, AlertCircle, Copy } from 'lucide-react';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
export default function VerifyTFAPage() {
|
||||
const router = useRouter();
|
||||
const {
|
||||
requiresTfa,
|
||||
tfaConfigured,
|
||||
completeTfaLogin,
|
||||
logout,
|
||||
} = useAuthStore();
|
||||
|
||||
const [code, setCode] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isFetchingSession, setIsFetchingSession] = useState(true);
|
||||
const [attempts, setAttempts] = useState(0);
|
||||
|
||||
// Session data fetched from server
|
||||
const [qrCodeBlobUrl, setQrCodeBlobUrl] = useState<string | null>(null);
|
||||
const [manualEntryKey, setManualEntryKey] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let blobUrl: string | null = null;
|
||||
|
||||
// Fetch TFA session data from server using HTTP-only cookie
|
||||
const fetchSessionData = async () => {
|
||||
if (!requiresTfa) {
|
||||
router.push('/auth/login');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsFetchingSession(true);
|
||||
|
||||
// Fetch session metadata
|
||||
const sessionData = await getTFASessionData();
|
||||
|
||||
if (sessionData.manual_entry_key) {
|
||||
setManualEntryKey(sessionData.manual_entry_key);
|
||||
}
|
||||
|
||||
// Fetch QR code as secure blob (if needed for setup)
|
||||
if (!sessionData.tfa_configured) {
|
||||
blobUrl = await getTFAQRCodeBlob();
|
||||
setQrCodeBlobUrl(blobUrl);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch TFA session data:', err);
|
||||
setError('Session expired. Please login again.');
|
||||
setTimeout(() => router.push('/auth/login'), 2000);
|
||||
} finally {
|
||||
setIsFetchingSession(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSessionData();
|
||||
|
||||
// Cleanup: revoke blob URL on unmount using local variable
|
||||
return () => {
|
||||
if (blobUrl) {
|
||||
URL.revokeObjectURL(blobUrl);
|
||||
}
|
||||
};
|
||||
}, [requiresTfa, router]);
|
||||
|
||||
const handleVerify = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
|
||||
// Validate code format (6 digits)
|
||||
if (!/^\d{6}$/.test(code)) {
|
||||
setError('Please enter a valid 6-digit code');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
// Session cookie automatically sent with request
|
||||
const result = await verifyTFALogin(code);
|
||||
|
||||
if (result.success && result.access_token) {
|
||||
// Extract user data from response
|
||||
const user = result.user;
|
||||
|
||||
// Update auth store with token and user
|
||||
completeTfaLogin(result.access_token, user);
|
||||
|
||||
// Redirect to tenant page
|
||||
router.push('/dashboard/tenants');
|
||||
} else {
|
||||
throw new Error(result.message || 'Verification failed');
|
||||
}
|
||||
} catch (err: any) {
|
||||
const newAttempts = attempts + 1;
|
||||
setAttempts(newAttempts);
|
||||
|
||||
if (newAttempts >= 5) {
|
||||
setError('Too many attempts. Please wait 60 seconds and try again.');
|
||||
} else {
|
||||
setError(err.message || 'Invalid verification code. Please try again.');
|
||||
}
|
||||
|
||||
setCode('');
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
logout();
|
||||
router.push('/auth/login');
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success('Copied to clipboard');
|
||||
};
|
||||
|
||||
// Show loading while fetching session data
|
||||
if (isFetchingSession) {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800">
|
||||
<div className="text-center">
|
||||
<Loader2 className="w-12 h-12 mx-auto mb-4 animate-spin text-primary" />
|
||||
<p className="text-muted-foreground">Loading TFA setup...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!requiresTfa) {
|
||||
return null; // Will redirect via useEffect
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center space-y-1">
|
||||
<div className="w-16 h-16 bg-primary rounded-lg flex items-center justify-center mx-auto mb-4">
|
||||
<Lock className="h-8 w-8 text-primary-foreground" />
|
||||
</div>
|
||||
<CardTitle className="text-2xl font-bold">
|
||||
{tfaConfigured ? 'Two-Factor Authentication' : 'Setup Two-Factor Authentication'}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{tfaConfigured
|
||||
? 'Enter the 6-digit code from your authenticator app'
|
||||
: 'Your administrator requires 2FA for your account'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
{/* Mode A: Setup (tfa_configured=false) */}
|
||||
{!tfaConfigured && qrCodeBlobUrl && (
|
||||
<div className="mb-6 space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold mb-3">Scan QR Code</h3>
|
||||
|
||||
{/* QR Code Display (secure blob URL - TOTP secret never in JavaScript) */}
|
||||
<div className="bg-white p-4 rounded-lg border-2 border-border mb-4 flex justify-center">
|
||||
<img
|
||||
src={qrCodeBlobUrl}
|
||||
alt="QR Code"
|
||||
className="w-48 h-48"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Manual Entry Key */}
|
||||
{manualEntryKey && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
Manual Entry Key
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<code className="flex-1 px-3 py-2 bg-muted border border-border rounded-lg text-sm font-mono">
|
||||
{manualEntryKey}
|
||||
</code>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => copyToClipboard(manualEntryKey.replace(/\s/g, ''))}
|
||||
>
|
||||
<Copy className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="bg-blue-50 dark:bg-blue-950 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
|
||||
<p className="text-sm font-semibold mb-2 text-blue-900 dark:text-blue-100">
|
||||
Instructions:
|
||||
</p>
|
||||
<ol className="text-sm text-blue-800 dark:text-blue-200 ml-4 list-decimal space-y-1">
|
||||
<li>Download Google Authenticator or any TOTP app</li>
|
||||
<li>Scan the QR code or enter the manual key as shown above</li>
|
||||
<li>Enter the 6-digit code below to complete setup</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Code Input (both modes) */}
|
||||
<form onSubmit={handleVerify} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-2">
|
||||
6-Digit Code
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
|
||||
placeholder="000000"
|
||||
maxLength={6}
|
||||
autoFocus
|
||||
disabled={isLoading || attempts >= 5}
|
||||
className="text-center text-2xl tracking-widest font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 dark:bg-red-950 border border-red-200 dark:border-red-800 rounded-lg p-3">
|
||||
<div className="flex items-center">
|
||||
<AlertCircle className="w-4 h-4 text-red-600 dark:text-red-400 mr-2" />
|
||||
<p className="text-sm text-red-700 dark:text-red-300">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{attempts > 0 && attempts < 5 && (
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
Attempts remaining: {5 - attempts}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex-1"
|
||||
disabled={isLoading || code.length !== 6 || attempts >= 5}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Verifying...
|
||||
</>
|
||||
) : (
|
||||
tfaConfigured ? 'Verify' : 'Verify and Complete Setup'
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* Only show cancel if TFA is already configured (optional flow) */}
|
||||
{tfaConfigured && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleCancel}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* No cancel button for mandatory setup (Mode A) */}
|
||||
{!tfaConfigured && (
|
||||
<div className="mt-4 text-center">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
2FA is required for your account. Contact your administrator if you need assistance.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Security Info */}
|
||||
<div className="absolute bottom-4 left-0 right-0 text-center text-sm text-muted-foreground">
|
||||
<p>GT 2.0 Control Panel • Enterprise Security</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
412
apps/control-panel-frontend/src/app/dashboard/api-keys/page.tsx
Normal file
412
apps/control-panel-frontend/src/app/dashboard/api-keys/page.tsx
Normal file
@@ -0,0 +1,412 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { apiKeysApi } from '@/lib/api';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import AddApiKeyDialog, { ProviderConfig } from '@/components/api-keys/AddApiKeyDialog';
|
||||
import {
|
||||
Key,
|
||||
Plus,
|
||||
TestTube,
|
||||
Trash2,
|
||||
Loader2,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
|
||||
// Hardcoded tenant for GT AI OS Local (single-tenant deployment)
|
||||
const TEST_COMPANY_TENANT = {
|
||||
id: 1,
|
||||
name: 'HW Workstation Test Deployment',
|
||||
domain: 'test-company',
|
||||
};
|
||||
|
||||
interface APIKeyStatus {
|
||||
configured: boolean;
|
||||
enabled: boolean;
|
||||
updated_at: string | null;
|
||||
metadata: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
// Provider configuration - NVIDIA first (above Groq), then Groq
|
||||
const PROVIDER_CONFIG: ProviderConfig[] = [
|
||||
{
|
||||
id: 'nvidia',
|
||||
name: 'NVIDIA NIM',
|
||||
description: 'GPU-accelerated inference on DGX Cloud via build.nvidia.com',
|
||||
keyPrefix: 'nvapi-',
|
||||
consoleUrl: 'https://build.nvidia.com/settings/api-keys',
|
||||
consoleName: 'build.nvidia.com',
|
||||
},
|
||||
{
|
||||
id: 'groq',
|
||||
name: 'Groq Cloud LLM',
|
||||
description: 'LPU-accelerated inference via api.groq.com',
|
||||
keyPrefix: 'gsk_',
|
||||
consoleUrl: 'https://console.groq.com/keys',
|
||||
consoleName: 'console.groq.com',
|
||||
},
|
||||
];
|
||||
|
||||
export default function ApiKeysPage() {
|
||||
// Auto-select test_company tenant for GT AI OS Local
|
||||
const selectedTenant = TEST_COMPANY_TENANT;
|
||||
const selectedTenantId = TEST_COMPANY_TENANT.id;
|
||||
|
||||
const [apiKeyStatus, setApiKeyStatus] = useState<Record<string, APIKeyStatus>>({});
|
||||
const [isLoadingKeys, setIsLoadingKeys] = useState(false);
|
||||
const [testingProvider, setTestingProvider] = useState<string | null>(null);
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
const [showRemoveDialog, setShowRemoveDialog] = useState(false);
|
||||
const [activeProvider, setActiveProvider] = useState<ProviderConfig | null>(null);
|
||||
const [testResults, setTestResults] = useState<Record<string, {
|
||||
success: boolean;
|
||||
message: string;
|
||||
error_type?: string;
|
||||
rate_limit_remaining?: number;
|
||||
rate_limit_reset?: string;
|
||||
models_available?: number;
|
||||
}>>({});
|
||||
|
||||
const { toast } = useToast();
|
||||
|
||||
// Fetch API keys for test_company tenant
|
||||
const fetchApiKeys = useCallback(async (tenantId: number) => {
|
||||
setIsLoadingKeys(true);
|
||||
setTestResults({});
|
||||
|
||||
try {
|
||||
const response = await apiKeysApi.getTenantKeys(tenantId);
|
||||
setApiKeyStatus(response.data || {});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch API keys:', error);
|
||||
setApiKeyStatus({});
|
||||
} finally {
|
||||
setIsLoadingKeys(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Load API keys on mount
|
||||
useEffect(() => {
|
||||
fetchApiKeys(selectedTenantId);
|
||||
}, [selectedTenantId, fetchApiKeys]);
|
||||
|
||||
const handleTestConnection = async (provider: ProviderConfig) => {
|
||||
if (!selectedTenantId) return;
|
||||
|
||||
setTestingProvider(provider.id);
|
||||
setTestResults((prev) => {
|
||||
const newResults = { ...prev };
|
||||
delete newResults[provider.id];
|
||||
return newResults;
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await apiKeysApi.testKey(selectedTenantId, provider.id);
|
||||
const result = response.data;
|
||||
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[provider.id]: {
|
||||
success: result.valid,
|
||||
message: result.message,
|
||||
error_type: result.error_type,
|
||||
rate_limit_remaining: result.rate_limit_remaining,
|
||||
rate_limit_reset: result.rate_limit_reset,
|
||||
models_available: result.models_available,
|
||||
},
|
||||
}));
|
||||
|
||||
// Build toast message with additional info
|
||||
let description = result.message;
|
||||
if (result.valid && result.models_available) {
|
||||
description += ` (${result.models_available} models available)`;
|
||||
}
|
||||
|
||||
toast({
|
||||
title: result.valid ? 'Connection Successful' : 'Connection Failed',
|
||||
description: description,
|
||||
variant: result.valid ? 'default' : 'destructive',
|
||||
});
|
||||
} catch (error) {
|
||||
const message = 'Failed to test connection';
|
||||
setTestResults((prev) => ({
|
||||
...prev,
|
||||
[provider.id]: { success: false, message, error_type: 'connection_error' },
|
||||
}));
|
||||
toast({
|
||||
title: 'Test Failed',
|
||||
description: message,
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setTestingProvider(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveKey = async () => {
|
||||
if (!selectedTenantId || !activeProvider) return;
|
||||
|
||||
try {
|
||||
await apiKeysApi.removeKey(selectedTenantId, activeProvider.id);
|
||||
toast({
|
||||
title: 'API Key Removed',
|
||||
description: `The ${activeProvider.name} API key has been removed`,
|
||||
});
|
||||
fetchApiKeys(selectedTenantId);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: 'Remove Failed',
|
||||
description: 'Failed to remove API key',
|
||||
variant: 'destructive',
|
||||
});
|
||||
} finally {
|
||||
setShowRemoveDialog(false);
|
||||
setActiveProvider(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyAdded = () => {
|
||||
if (selectedTenantId) {
|
||||
fetchApiKeys(selectedTenantId);
|
||||
}
|
||||
};
|
||||
|
||||
const openAddDialog = (provider: ProviderConfig) => {
|
||||
setActiveProvider(provider);
|
||||
setShowAddDialog(true);
|
||||
};
|
||||
|
||||
const openRemoveDialog = (provider: ProviderConfig) => {
|
||||
setActiveProvider(provider);
|
||||
setShowRemoveDialog(true);
|
||||
};
|
||||
|
||||
const formatDate = (dateString: string | null) => {
|
||||
if (!dateString) return 'Never';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">API Keys</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage API keys for external AI providers
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* API Keys Section - One card per provider */}
|
||||
{selectedTenant && (
|
||||
<div className="space-y-6">
|
||||
{PROVIDER_CONFIG.map((provider) => {
|
||||
const keyStatus = apiKeyStatus[provider.id];
|
||||
const testResult = testResults[provider.id];
|
||||
const isTesting = testingProvider === provider.id;
|
||||
|
||||
return (
|
||||
<Card key={provider.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Key className="h-5 w-5" />
|
||||
{provider.name} API Key
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{provider.description} for {selectedTenant.name}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{!keyStatus?.configured && (
|
||||
<Button onClick={() => openAddDialog(provider)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Key
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingKeys ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : keyStatus?.configured ? (
|
||||
<div className="space-y-4">
|
||||
{/* Status Badges */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">
|
||||
<CheckCircle className="mr-1 h-3 w-3" />
|
||||
Configured
|
||||
</Badge>
|
||||
<Badge
|
||||
variant={keyStatus.enabled ? 'default' : 'destructive'}
|
||||
>
|
||||
{keyStatus.enabled ? 'Enabled' : 'Disabled'}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Key Display */}
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>Key:</span>
|
||||
<code className="bg-muted px-2 py-1 rounded font-mono">
|
||||
{provider.keyPrefix}••••••••••••••••••••
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Last Updated */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Last updated: {formatDate(keyStatus.updated_at)}
|
||||
</div>
|
||||
|
||||
{/* Test Result */}
|
||||
{testResult && (
|
||||
<div
|
||||
className={`flex flex-col gap-2 p-3 rounded-md text-sm ${
|
||||
testResult.success
|
||||
? 'bg-green-50 text-green-700 border border-green-200'
|
||||
: testResult.error_type === 'rate_limited'
|
||||
? 'bg-yellow-50 text-yellow-700 border border-yellow-200'
|
||||
: 'bg-red-50 text-red-700 border border-red-200'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{testResult.success ? (
|
||||
<CheckCircle className="h-4 w-4 flex-shrink-0" />
|
||||
) : testResult.error_type === 'rate_limited' ? (
|
||||
<AlertTriangle className="h-4 w-4 flex-shrink-0" />
|
||||
) : (
|
||||
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||
)}
|
||||
<span>{testResult.message}</span>
|
||||
</div>
|
||||
{/* Additional info row */}
|
||||
{(testResult.models_available || testResult.rate_limit_remaining !== undefined) && (
|
||||
<div className="flex items-center gap-4 text-xs opacity-80 ml-6">
|
||||
{testResult.models_available && (
|
||||
<span>{testResult.models_available} models available</span>
|
||||
)}
|
||||
{testResult.rate_limit_remaining !== undefined && (
|
||||
<span>Rate limit: {testResult.rate_limit_remaining} remaining</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-4 pt-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleTestConnection(provider)}
|
||||
disabled={isTesting || !keyStatus.enabled}
|
||||
>
|
||||
{isTesting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Testing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TestTube className="mr-2 h-4 w-4" />
|
||||
Test Connection
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => openAddDialog(provider)}
|
||||
>
|
||||
Update Key
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="text-destructive hover:text-destructive"
|
||||
onClick={() => openRemoveDialog(provider)}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<XCircle className="mx-auto h-12 w-12 text-muted-foreground/50" />
|
||||
<h3 className="mt-4 text-lg font-medium">No API Key Configured</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Add a {provider.name} API key to enable AI inference for this tenant.
|
||||
</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
onClick={() => openAddDialog(provider)}
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Configure API Key
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add/Edit Dialog */}
|
||||
{selectedTenant && activeProvider && (
|
||||
<AddApiKeyDialog
|
||||
open={showAddDialog}
|
||||
onOpenChange={setShowAddDialog}
|
||||
tenantId={selectedTenant.id}
|
||||
tenantName={selectedTenant.name}
|
||||
existingKey={apiKeyStatus[activeProvider.id]?.configured}
|
||||
onKeyAdded={handleKeyAdded}
|
||||
provider={activeProvider}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Remove Confirmation Dialog */}
|
||||
<AlertDialog open={showRemoveDialog} onOpenChange={setShowRemoveDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Remove API Key?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently remove the {activeProvider?.name} API key for{' '}
|
||||
<strong>{selectedTenant?.name}</strong>. AI inference using this provider will stop
|
||||
working for this tenant until a new key is configured.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleRemoveKey}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Remove Key
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { DashboardNav } from '@/components/layout/dashboard-nav';
|
||||
import { DashboardHeader } from '@/components/layout/dashboard-header';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const { user, isLoading, isAuthenticated, checkAuth } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, [checkAuth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading && !isAuthenticated) {
|
||||
router.replace('/auth/login');
|
||||
}
|
||||
}, [isAuthenticated, isLoading, router]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-muted-foreground">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isAuthenticated || !user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
<DashboardHeader />
|
||||
<div className="flex">
|
||||
<DashboardNav />
|
||||
<main className="flex-1 p-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
32
apps/control-panel-frontend/src/app/dashboard/layout.tsx
Normal file
32
apps/control-panel-frontend/src/app/dashboard/layout.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
'use client';
|
||||
|
||||
import { DashboardNav } from '@/components/layout/dashboard-nav';
|
||||
import { DashboardHeader } from '@/components/layout/dashboard-header';
|
||||
import { AuthGuard } from '@/components/auth/auth-guard';
|
||||
import { ErrorBoundary } from '@/components/ui/error-boundary';
|
||||
import { UpdateBanner } from '@/components/system/UpdateBanner';
|
||||
|
||||
export default function DashboardLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<AuthGuard>
|
||||
<ErrorBoundary>
|
||||
<div className="min-h-screen bg-background">
|
||||
<DashboardHeader />
|
||||
<div className="flex">
|
||||
<DashboardNav />
|
||||
<main className="flex-1 p-6">
|
||||
<UpdateBanner />
|
||||
<ErrorBoundary>
|
||||
{children}
|
||||
</ErrorBoundary>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
</AuthGuard>
|
||||
);
|
||||
}
|
||||
308
apps/control-panel-frontend/src/app/dashboard/models/page.tsx
Normal file
308
apps/control-panel-frontend/src/app/dashboard/models/page.tsx
Normal file
@@ -0,0 +1,308 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Plus, Cpu, Activity } from 'lucide-react';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import ModelRegistryTable from '@/components/models/ModelRegistryTable';
|
||||
import EndpointConfigurator from '@/components/models/EndpointConfigurator';
|
||||
import AddModelDialog from '@/components/models/AddModelDialog';
|
||||
|
||||
interface ModelStats {
|
||||
total_models: number;
|
||||
active_models: number;
|
||||
inactive_models: number;
|
||||
providers: Record<string, number>;
|
||||
}
|
||||
|
||||
interface ModelConfig {
|
||||
model_id: string;
|
||||
name: string;
|
||||
provider: string;
|
||||
model_type: string;
|
||||
endpoint: string;
|
||||
description: string | null;
|
||||
health_status: 'healthy' | 'unhealthy' | 'unknown';
|
||||
is_active: boolean;
|
||||
context_window?: number;
|
||||
max_tokens?: number;
|
||||
dimensions?: number;
|
||||
cost_per_million_input?: number;
|
||||
cost_per_million_output?: number;
|
||||
capabilities?: Record<string, any>;
|
||||
last_health_check?: string;
|
||||
created_at: string;
|
||||
specifications?: {
|
||||
context_window: number | null;
|
||||
max_tokens: number | null;
|
||||
dimensions: number | null;
|
||||
};
|
||||
cost?: {
|
||||
per_million_input: number;
|
||||
per_million_output: number;
|
||||
};
|
||||
status?: {
|
||||
is_active: boolean;
|
||||
health_status: string;
|
||||
};
|
||||
}
|
||||
|
||||
export default function ModelsPage() {
|
||||
const [showAddDialog, setShowAddDialog] = useState(false);
|
||||
const [activeTab, setActiveTab] = useState('registry');
|
||||
const [stats, setStats] = useState<ModelStats | null>(null);
|
||||
const [models, setModels] = useState<ModelConfig[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [lastFetch, setLastFetch] = useState<number>(0);
|
||||
const { toast } = useToast();
|
||||
|
||||
// Cache data for 30 seconds to prevent excessive requests
|
||||
const CACHE_DURATION = 30000;
|
||||
|
||||
// Fetch all data once at the top level
|
||||
useEffect(() => {
|
||||
const fetchAllData = async () => {
|
||||
// Check cache first
|
||||
const now = Date.now();
|
||||
if (models.length > 0 && now - lastFetch < CACHE_DURATION) {
|
||||
console.log('Using cached data, skipping API call');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Fetch both stats and models in parallel
|
||||
const [statsResponse, modelsResponse] = await Promise.all([
|
||||
fetch('/api/v1/models/stats/overview', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}),
|
||||
fetch('/api/v1/models?include_stats=true', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
]);
|
||||
|
||||
if (!statsResponse.ok) {
|
||||
throw new Error(`Stats error! status: ${statsResponse.status}`);
|
||||
}
|
||||
|
||||
if (!modelsResponse.ok) {
|
||||
throw new Error(`Models error! status: ${modelsResponse.status}`);
|
||||
}
|
||||
|
||||
const [statsData, modelsData] = await Promise.all([
|
||||
statsResponse.json(),
|
||||
modelsResponse.json()
|
||||
]);
|
||||
|
||||
setStats(statsData);
|
||||
|
||||
// Map API response to component interface
|
||||
const mappedModels: ModelConfig[] = modelsData.map((model: any) => ({
|
||||
model_id: model.model_id,
|
||||
name: model.name,
|
||||
provider: model.provider,
|
||||
model_type: model.model_type,
|
||||
endpoint: model.endpoint,
|
||||
description: model.description,
|
||||
health_status: model.status?.health_status || 'unknown',
|
||||
is_active: model.status?.is_active || false,
|
||||
context_window: model.specifications?.context_window,
|
||||
max_tokens: model.specifications?.max_tokens,
|
||||
dimensions: model.specifications?.dimensions,
|
||||
cost_per_million_input: model.cost?.per_million_input || 0,
|
||||
cost_per_million_output: model.cost?.per_million_output || 0,
|
||||
capabilities: model.capabilities || {},
|
||||
last_health_check: model.status?.last_health_check,
|
||||
created_at: model.timestamps?.created_at,
|
||||
specifications: model.specifications,
|
||||
cost: model.cost,
|
||||
status: model.status,
|
||||
}));
|
||||
|
||||
setModels(mappedModels);
|
||||
setLastFetch(Date.now());
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch data:', error);
|
||||
toast({
|
||||
title: "Failed to Load Data",
|
||||
description: "Unable to fetch model data from the server",
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAllData();
|
||||
}, []); // Remove toast dependency to prevent re-renders
|
||||
|
||||
// Refresh data when models are updated
|
||||
const handleModelUpdated = () => {
|
||||
setLoading(true);
|
||||
const fetchAllData = async () => {
|
||||
try {
|
||||
console.log('Model updated, forcing fresh data fetch');
|
||||
const [statsResponse, modelsResponse] = await Promise.all([
|
||||
fetch('/api/v1/models/stats/overview', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}),
|
||||
fetch('/api/v1/models?include_stats=true', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${localStorage.getItem('authToken')}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
]);
|
||||
|
||||
if (statsResponse.ok && modelsResponse.ok) {
|
||||
const [statsData, modelsData] = await Promise.all([
|
||||
statsResponse.json(),
|
||||
modelsResponse.json()
|
||||
]);
|
||||
|
||||
setStats(statsData);
|
||||
|
||||
const mappedModels: ModelConfig[] = modelsData.map((model: any) => ({
|
||||
model_id: model.model_id,
|
||||
name: model.name,
|
||||
provider: model.provider,
|
||||
model_type: model.model_type,
|
||||
endpoint: model.endpoint,
|
||||
description: model.description,
|
||||
health_status: model.status?.health_status || 'unknown',
|
||||
is_active: model.status?.is_active || false,
|
||||
context_window: model.specifications?.context_window,
|
||||
max_tokens: model.specifications?.max_tokens,
|
||||
dimensions: model.specifications?.dimensions,
|
||||
cost_per_1k_input: model.cost?.per_1k_input || 0,
|
||||
cost_per_1k_output: model.cost?.per_1k_output || 0,
|
||||
capabilities: model.capabilities || {},
|
||||
last_health_check: model.status?.last_health_check,
|
||||
created_at: model.timestamps?.created_at,
|
||||
specifications: model.specifications,
|
||||
cost: model.cost,
|
||||
status: model.status,
|
||||
}));
|
||||
|
||||
setModels(mappedModels);
|
||||
setLastFetch(Date.now());
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh data:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchAllData();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Models</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Configure AI model endpoints and providers
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowAddDialog(true)} className="flex items-center gap-2">
|
||||
<Plus className="w-4 h-4" />
|
||||
Add Model
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Models</CardTitle>
|
||||
<Cpu className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? '...' : (stats?.total_models || 0)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{loading ? 'Loading...' : `${stats?.active_models || 0} active, ${stats?.inactive_models || 0} inactive`}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Active Models</CardTitle>
|
||||
<Activity className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{loading ? '...' : (stats?.active_models || 0)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Available for tenant use
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Main Content Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="space-y-4">
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="registry">Model Registry</TabsTrigger>
|
||||
<TabsTrigger value="endpoints">Endpoint Configuration</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="registry" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Registered Models</CardTitle>
|
||||
<CardDescription>
|
||||
Manage AI models available for your tenant. Use the delete option to permanently remove models.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ModelRegistryTable
|
||||
showArchived={false}
|
||||
models={models}
|
||||
loading={loading}
|
||||
onModelUpdated={handleModelUpdated}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="endpoints" className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Endpoint Configurations</CardTitle>
|
||||
<CardDescription>
|
||||
API endpoints for model providers
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<EndpointConfigurator />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
{/* Add Model Dialog */}
|
||||
<AddModelDialog
|
||||
open={showAddDialog}
|
||||
onOpenChange={setShowAddDialog}
|
||||
onModelAdded={handleModelUpdated}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Activity, AlertTriangle, BarChart3, Clock, Cpu, Database, Globe, Loader2, TrendingUp, Users } from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { monitoringApi } from '@/lib/api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface SystemMetrics {
|
||||
cpu_usage: number;
|
||||
memory_usage: number;
|
||||
disk_usage: number;
|
||||
network_io: number;
|
||||
active_connections: number;
|
||||
api_calls_per_minute: number;
|
||||
}
|
||||
|
||||
interface TenantMetric {
|
||||
tenant_id: number;
|
||||
tenant_name: string;
|
||||
api_calls: number;
|
||||
storage_used: number;
|
||||
active_users: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface Alert {
|
||||
id: number;
|
||||
severity: string;
|
||||
title: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
acknowledged: boolean;
|
||||
}
|
||||
|
||||
export default function MonitoringPage() {
|
||||
const [systemMetrics, setSystemMetrics] = useState<SystemMetrics | null>(null);
|
||||
const [tenantMetrics, setTenantMetrics] = useState<TenantMetric[]>([]);
|
||||
const [alerts, setAlerts] = useState<Alert[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('24h');
|
||||
const [refreshInterval, setRefreshInterval] = useState<NodeJS.Timeout | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchMonitoringData();
|
||||
|
||||
// Set up auto-refresh every 30 seconds
|
||||
const interval = setInterval(() => {
|
||||
fetchMonitoringData();
|
||||
}, 30000);
|
||||
|
||||
setRefreshInterval(interval);
|
||||
|
||||
return () => {
|
||||
if (interval) clearInterval(interval);
|
||||
};
|
||||
}, [selectedPeriod]);
|
||||
|
||||
const fetchMonitoringData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Fetch all monitoring data in parallel
|
||||
const [systemResponse, tenantResponse, alertsResponse] = await Promise.all([
|
||||
monitoringApi.systemMetrics().catch(() => null),
|
||||
monitoringApi.tenantMetrics().catch(() => null),
|
||||
monitoringApi.alerts(1, 20).catch(() => null)
|
||||
]);
|
||||
|
||||
// Set data from API responses or empty defaults
|
||||
setSystemMetrics(systemResponse?.data || {
|
||||
cpu_usage: 0,
|
||||
memory_usage: 0,
|
||||
disk_usage: 0,
|
||||
network_io: 0,
|
||||
active_connections: 0,
|
||||
api_calls_per_minute: 0
|
||||
});
|
||||
|
||||
setTenantMetrics(tenantResponse?.data?.tenants || []);
|
||||
setAlerts(alertsResponse?.data?.alerts || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch monitoring data:', error);
|
||||
toast.error('Failed to load monitoring data');
|
||||
|
||||
// Set empty data on error
|
||||
setSystemMetrics({
|
||||
cpu_usage: 0,
|
||||
memory_usage: 0,
|
||||
disk_usage: 0,
|
||||
network_io: 0,
|
||||
active_connections: 0,
|
||||
api_calls_per_minute: 0
|
||||
});
|
||||
setTenantMetrics([]);
|
||||
setAlerts([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return <Badge variant="default" className="bg-green-600">Active</Badge>;
|
||||
case 'warning':
|
||||
return <Badge variant="default" className="bg-yellow-600">Warning</Badge>;
|
||||
case 'critical':
|
||||
return <Badge variant="destructive">Critical</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityBadge = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'critical':
|
||||
return <Badge variant="destructive">Critical</Badge>;
|
||||
case 'warning':
|
||||
return <Badge variant="default" className="bg-yellow-600">Warning</Badge>;
|
||||
case 'info':
|
||||
return <Badge variant="secondary">Info</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary">{severity}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const formatPercentage = (value: number) => {
|
||||
return `${Math.round(value)}%`;
|
||||
};
|
||||
|
||||
const getUsageColor = (value: number) => {
|
||||
if (value > 80) return 'text-red-600';
|
||||
if (value > 60) return 'text-yellow-600';
|
||||
return 'text-green-600';
|
||||
};
|
||||
|
||||
if (isLoading && !systemMetrics) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[600px]">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<span className="text-muted-foreground">Loading monitoring data...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">System Monitoring</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Real-time system metrics and performance monitoring
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Select value={selectedPeriod} onValueChange={setSelectedPeriod}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1h">Last Hour</SelectItem>
|
||||
<SelectItem value="24h">Last 24h</SelectItem>
|
||||
<SelectItem value="7d">Last 7 Days</SelectItem>
|
||||
<SelectItem value="30d">Last 30 Days</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={fetchMonitoringData} variant="secondary">
|
||||
<Activity className="mr-2 h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* System Metrics */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium flex items-center">
|
||||
<Cpu className="h-4 w-4 mr-2" />
|
||||
CPU Usage
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className={`text-2xl font-bold ${getUsageColor(systemMetrics?.cpu_usage || 0)}`}>
|
||||
{formatPercentage(systemMetrics?.cpu_usage || 0)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium flex items-center">
|
||||
<Database className="h-4 w-4 mr-2" />
|
||||
Memory
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className={`text-2xl font-bold ${getUsageColor(systemMetrics?.memory_usage || 0)}`}>
|
||||
{formatPercentage(systemMetrics?.memory_usage || 0)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium flex items-center">
|
||||
<Database className="h-4 w-4 mr-2" />
|
||||
Disk Usage
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className={`text-2xl font-bold ${getUsageColor(systemMetrics?.disk_usage || 0)}`}>
|
||||
{formatPercentage(systemMetrics?.disk_usage || 0)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium flex items-center">
|
||||
<Globe className="h-4 w-4 mr-2" />
|
||||
Network I/O
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{systemMetrics?.network_io || 0} MB/s
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium flex items-center">
|
||||
<Users className="h-4 w-4 mr-2" />
|
||||
Connections
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{systemMetrics?.active_connections || 0}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium flex items-center">
|
||||
<TrendingUp className="h-4 w-4 mr-2" />
|
||||
API Calls/min
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{systemMetrics?.api_calls_per_minute || 0}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Tenant Metrics */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Tenant Activity</CardTitle>
|
||||
<CardDescription>Resource usage by tenant</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Tenant</TableHead>
|
||||
<TableHead>API Calls</TableHead>
|
||||
<TableHead>Storage</TableHead>
|
||||
<TableHead>Users</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tenantMetrics.map((tenant) => (
|
||||
<TableRow key={tenant.tenant_id}>
|
||||
<TableCell className="font-medium">{tenant.tenant_name}</TableCell>
|
||||
<TableCell>{tenant.api_calls.toLocaleString()}</TableCell>
|
||||
<TableCell>{tenant.storage_used} GB</TableCell>
|
||||
<TableCell>{tenant.active_users}</TableCell>
|
||||
<TableCell>{getStatusBadge(tenant.status)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Alerts */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<AlertTriangle className="h-5 w-5 mr-2" />
|
||||
Recent Alerts
|
||||
</CardTitle>
|
||||
<CardDescription>System alerts and notifications</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{alerts.length === 0 ? (
|
||||
<p className="text-center text-muted-foreground py-4">No active alerts</p>
|
||||
) : (
|
||||
alerts.map((alert) => (
|
||||
<div key={alert.id} className="border rounded-lg p-3 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
{getSeverityBadge(alert.severity)}
|
||||
<span className="font-medium">{alert.title}</span>
|
||||
</div>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{new Date(alert.timestamp).toLocaleTimeString()}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{alert.description}</p>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Performance Graph Placeholder */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<BarChart3 className="h-5 w-5 mr-2" />
|
||||
Performance Trends
|
||||
</CardTitle>
|
||||
<CardDescription>System performance over time</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[300px] flex items-center justify-center border-2 border-dashed rounded-lg">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<BarChart3 className="h-12 w-12 mx-auto mb-4" />
|
||||
<p>Performance charts will be displayed here</p>
|
||||
<p className="text-sm mt-2">Coming soon: Real-time graphs and analytics</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
14
apps/control-panel-frontend/src/app/dashboard/page.tsx
Normal file
14
apps/control-panel-frontend/src/app/dashboard/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export default function DashboardPage() {
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
router.replace('/dashboard/tenants');
|
||||
}, [router]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error('Agent library page error:', error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[600px] p-6">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 w-16 h-16 flex items-center justify-center rounded-full bg-red-100">
|
||||
<AlertTriangle className="w-8 h-8 text-red-600" />
|
||||
</div>
|
||||
<CardTitle className="text-red-600">Failed to load agent library</CardTitle>
|
||||
<CardDescription>
|
||||
There was a problem loading the agent library page. This could be due to a network issue or server error.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-sm text-muted-foreground bg-muted p-3 rounded-md">
|
||||
<strong>Error:</strong> {error.message}
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button onClick={reset} className="flex-1">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Try Again
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => window.location.reload()} className="flex-1">
|
||||
Reload Page
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageLoading } from '@/components/ui/loading';
|
||||
|
||||
export default function Loading() {
|
||||
return <PageLoading text="Loading agent library..." />;
|
||||
}
|
||||
@@ -0,0 +1,533 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
Bot,
|
||||
Brain,
|
||||
Code,
|
||||
Shield,
|
||||
GraduationCap,
|
||||
Activity,
|
||||
Eye,
|
||||
Edit,
|
||||
Download,
|
||||
Upload,
|
||||
Users,
|
||||
Building2,
|
||||
Star,
|
||||
Clock,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
MoreVertical,
|
||||
Copy,
|
||||
Trash2,
|
||||
Play,
|
||||
Settings,
|
||||
GitBranch,
|
||||
Zap,
|
||||
Target,
|
||||
} from 'lucide-react';
|
||||
import { assistantLibraryApi, tenantsApi } from '@/lib/api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface ResourceTemplate {
|
||||
id: string;
|
||||
template_id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
category: string; // startup, standard, enterprise
|
||||
monthly_cost: number;
|
||||
resources: {
|
||||
cpu?: { limit: number; unit: string };
|
||||
memory?: { limit: number; unit: string };
|
||||
storage?: { limit: number; unit: string };
|
||||
api_calls?: { limit: number; unit: string };
|
||||
model_inference?: { limit: number; unit: string };
|
||||
gpu_time?: { limit: number; unit: string };
|
||||
};
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
is_active: boolean;
|
||||
icon: string;
|
||||
status: string;
|
||||
popularity_score: number;
|
||||
deployment_count: number;
|
||||
active_instances: number;
|
||||
version: string;
|
||||
capabilities: string[];
|
||||
access_groups: string[];
|
||||
}
|
||||
|
||||
export default function ResourceTemplatesPage() {
|
||||
const [templates, setTemplates] = useState<ResourceTemplate[]>([]);
|
||||
const [filteredTemplates, setFilteredTemplates] = useState<ResourceTemplate[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState('all');
|
||||
const [selectedTemplates, setSelectedTemplates] = useState<Set<string>>(new Set());
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<ResourceTemplate | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetchResourceTemplates();
|
||||
}, []);
|
||||
|
||||
const fetchResourceTemplates = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
// Use the existing resource management API to get templates (relative URL goes through Next.js rewrites)
|
||||
const response = await fetch('/api/v1/resource-management/templates');
|
||||
const data = await response.json();
|
||||
|
||||
// Transform the data to match our interface
|
||||
const templatesData = Object.entries(data.templates || {}).map(([key, template]: [string, any]) => ({
|
||||
id: key,
|
||||
template_id: key,
|
||||
name: template.display_name,
|
||||
description: template.description,
|
||||
category: template.name,
|
||||
monthly_cost: template.monthly_cost,
|
||||
resources: template.resources,
|
||||
created_at: new Date().toISOString(),
|
||||
updated_at: new Date().toISOString(),
|
||||
is_active: true,
|
||||
icon: template.icon || '🏢',
|
||||
status: template.status || 'active',
|
||||
popularity_score: template.popularity_score || 85,
|
||||
deployment_count: template.deployment_count || 12,
|
||||
active_instances: template.active_instances || 45,
|
||||
version: template.version || '1.0.0',
|
||||
capabilities: template.capabilities || ['basic_inference', 'text_generation'],
|
||||
access_groups: template.access_groups || ['standard_users', 'developers']
|
||||
}));
|
||||
|
||||
setTemplates(templatesData);
|
||||
setFilteredTemplates(templatesData);
|
||||
|
||||
} catch (error) {
|
||||
console.warn('Failed to fetch resource templates:', error);
|
||||
// Use fallback data from GT 2.0 architecture
|
||||
const fallbackTemplates = [
|
||||
{
|
||||
id: "startup",
|
||||
template_id: "startup",
|
||||
name: "Startup",
|
||||
description: "Basic resources for small teams and development",
|
||||
category: "startup",
|
||||
monthly_cost: 99.0,
|
||||
resources: {
|
||||
cpu: { limit: 2.0, unit: "cores" },
|
||||
memory: { limit: 4096, unit: "MB" },
|
||||
storage: { limit: 10240, unit: "MB" },
|
||||
api_calls: { limit: 10000, unit: "calls/hour" },
|
||||
model_inference: { limit: 1000, unit: "tokens" }
|
||||
},
|
||||
created_at: "2024-01-10T14:20:00Z",
|
||||
updated_at: "2024-01-15T10:30:00Z",
|
||||
is_active: true,
|
||||
icon: "🚀",
|
||||
status: "active",
|
||||
popularity_score: 92,
|
||||
deployment_count: 15,
|
||||
active_instances: 32,
|
||||
version: "1.2.1",
|
||||
capabilities: ["basic_inference", "text_generation", "code_analysis"],
|
||||
access_groups: ["startup_users", "basic_developers"]
|
||||
},
|
||||
{
|
||||
id: "standard",
|
||||
template_id: "standard",
|
||||
name: "Standard",
|
||||
description: "Standard resources for production workloads",
|
||||
category: "standard",
|
||||
monthly_cost: 299.0,
|
||||
resources: {
|
||||
cpu: { limit: 4.0, unit: "cores" },
|
||||
memory: { limit: 8192, unit: "MB" },
|
||||
storage: { limit: 51200, unit: "MB" },
|
||||
api_calls: { limit: 50000, unit: "calls/hour" },
|
||||
model_inference: { limit: 10000, unit: "tokens" }
|
||||
},
|
||||
created_at: "2024-01-05T09:15:00Z",
|
||||
updated_at: "2024-01-12T16:45:00Z",
|
||||
is_active: true,
|
||||
icon: "📈",
|
||||
status: "active",
|
||||
popularity_score: 88,
|
||||
deployment_count: 8,
|
||||
active_instances: 28,
|
||||
version: "1.1.0",
|
||||
capabilities: ["basic_inference", "text_generation", "data_analysis", "visualization"],
|
||||
access_groups: ["standard_users", "data_analysts", "developers"]
|
||||
},
|
||||
{
|
||||
id: "enterprise",
|
||||
template_id: "enterprise",
|
||||
name: "Enterprise",
|
||||
description: "High-performance resources for large organizations",
|
||||
category: "enterprise",
|
||||
monthly_cost: 999.0,
|
||||
resources: {
|
||||
cpu: { limit: 16.0, unit: "cores" },
|
||||
memory: { limit: 32768, unit: "MB" },
|
||||
storage: { limit: 102400, unit: "MB" },
|
||||
api_calls: { limit: 200000, unit: "calls/hour" },
|
||||
model_inference: { limit: 100000, unit: "tokens" },
|
||||
gpu_time: { limit: 1000, unit: "minutes" }
|
||||
},
|
||||
created_at: "2024-01-01T08:30:00Z",
|
||||
updated_at: "2024-01-18T11:20:00Z",
|
||||
is_active: true,
|
||||
icon: "🏢",
|
||||
status: "active",
|
||||
popularity_score: 95,
|
||||
deployment_count: 22,
|
||||
active_instances: 67,
|
||||
version: "2.0.0",
|
||||
capabilities: ["advanced_inference", "multimodal", "code_generation", "function_calling", "custom_training"],
|
||||
access_groups: ["enterprise_users", "power_users", "admin_users", "ml_engineers"]
|
||||
}
|
||||
];
|
||||
setTemplates(fallbackTemplates);
|
||||
setFilteredTemplates(fallbackTemplates);
|
||||
toast.error('Using cached template data - some features may be limited');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Mock data removed - now using real API calls above
|
||||
|
||||
// Filter templates based on search and category
|
||||
useEffect(() => {
|
||||
let filtered = templates;
|
||||
|
||||
// Filter by category
|
||||
if (categoryFilter !== 'all') {
|
||||
filtered = filtered.filter(t => t.category === categoryFilter);
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery) {
|
||||
filtered = filtered.filter(t =>
|
||||
t.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
t.description.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredTemplates(filtered);
|
||||
}, [categoryFilter, searchQuery, templates]);
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'published':
|
||||
return <Badge variant="default" className="bg-green-600"><CheckCircle className="h-3 w-3 mr-1" />Published</Badge>;
|
||||
case 'testing':
|
||||
return <Badge variant="secondary" className="bg-blue-600"><Activity className="h-3 w-3 mr-1" />Testing</Badge>;
|
||||
case 'draft':
|
||||
return <Badge variant="secondary"><Edit className="h-3 w-3 mr-1" />Draft</Badge>;
|
||||
case 'deprecated':
|
||||
return <Badge variant="destructive"><AlertTriangle className="h-3 w-3 mr-1" />Deprecated</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryIcon = (category: string) => {
|
||||
switch (category) {
|
||||
case 'cybersecurity':
|
||||
return <Shield className="h-4 w-4" />;
|
||||
case 'education':
|
||||
return <GraduationCap className="h-4 w-4" />;
|
||||
case 'research':
|
||||
return <Brain className="h-4 w-4" />;
|
||||
case 'development':
|
||||
return <Code className="h-4 w-4" />;
|
||||
case 'general':
|
||||
return <Bot className="h-4 w-4" />;
|
||||
default:
|
||||
return <Bot className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getCategoryBadge = (category: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
cybersecurity: 'bg-red-600',
|
||||
education: 'bg-green-600',
|
||||
research: 'bg-purple-600',
|
||||
development: 'bg-blue-600',
|
||||
general: 'bg-gray-600',
|
||||
};
|
||||
return (
|
||||
<Badge className={colors[category] || 'bg-gray-600'}>
|
||||
{getCategoryIcon(category)}
|
||||
<span className="ml-1">{category.charAt(0).toUpperCase() + category.slice(1)}</span>
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const categoryTabs = [
|
||||
{ id: 'all', label: 'All Templates', count: templates.length },
|
||||
{ id: 'startup', label: 'Startup', count: templates.filter(t => t.category === 'startup').length },
|
||||
{ id: 'standard', label: 'Standard', count: templates.filter(t => t.category === 'standard').length },
|
||||
{ id: 'enterprise', label: 'Enterprise', count: templates.filter(t => t.category === 'enterprise').length },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Resource Templates</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Resource allocation templates for tenant provisioning (startup, standard, enterprise)
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Template
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Analytics Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Available Templates</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{templates.length}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{templates.filter(t => t.is_active).length} active
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Cost Range</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
${templates.length > 0 ? Math.min(...templates.map(t => t.monthly_cost)) : 0} - ${templates.length > 0 ? Math.max(...templates.map(t => t.monthly_cost)) : 0}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Monthly pricing
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Most Popular</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">Standard</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Production workloads
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Category Tabs */}
|
||||
<div className="flex space-x-2 border-b">
|
||||
{categoryTabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setCategoryFilter(tab.id)}
|
||||
className={`px-4 py-2 border-b-2 transition-colors ${
|
||||
categoryFilter === tab.id
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
<Badge variant="secondary" className="ml-2">{tab.count}</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex space-x-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search resource templates by name or description..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery((e as React.ChangeEvent<HTMLInputElement>).target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk Actions */}
|
||||
{selectedTemplates.size > 0 && (
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="flex items-center justify-between py-3">
|
||||
<span className="text-sm">
|
||||
{selectedTemplates.size} template{selectedTemplates.size > 1 ? 's' : ''} selected
|
||||
</span>
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="secondary" size="sm">
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Bulk Deploy
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm">
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Duplicate
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" className="text-destructive">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Archive
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Template Gallery */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Activity className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredTemplates.map(template => (
|
||||
<Card key={template.id} className="hover:shadow-lg transition-shadow cursor-pointer">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<div className="text-2xl">{template.icon}</div>
|
||||
<div>
|
||||
<CardTitle className="text-lg">{template.name}</CardTitle>
|
||||
<div className="flex items-center space-x-2 mt-1">
|
||||
{getCategoryBadge(template.category)}
|
||||
{getStatusBadge(template.status)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedTemplates.has(template.id)}
|
||||
onChange={(e) => {
|
||||
const newSelected = new Set(selectedTemplates);
|
||||
if (e.target.checked) {
|
||||
newSelected.add(template.id);
|
||||
} else {
|
||||
newSelected.delete(template.id);
|
||||
}
|
||||
setSelectedTemplates(newSelected);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<CardDescription className="text-sm">
|
||||
{template.description}
|
||||
</CardDescription>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Popularity:</span>
|
||||
<div className="flex items-center space-x-1">
|
||||
<Star className="h-3 w-3 text-yellow-500" />
|
||||
<span className="font-medium">{template.popularity_score}%</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Deployments:</span>
|
||||
<span className="font-medium">{template.deployment_count}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Active Instances:</span>
|
||||
<span className="font-medium">{template.active_instances}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">Version:</span>
|
||||
<span className="font-medium">v{template.version}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm text-muted-foreground">Capabilities:</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{template.capabilities.slice(0, 3).map(capability => (
|
||||
<Badge key={capability} variant="secondary" className="text-xs">
|
||||
{capability.replace('_', ' ')}
|
||||
</Badge>
|
||||
))}
|
||||
{template.capabilities.length > 3 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
+{template.capabilities.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<span className="text-sm text-muted-foreground">Access Groups:</span>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{template.access_groups.slice(0, 2).map(group => (
|
||||
<Badge key={group} variant="secondary" className="text-xs">
|
||||
{group.replace('_', ' ')}
|
||||
</Badge>
|
||||
))}
|
||||
{template.access_groups.length > 2 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
+{template.access_groups.length - 2}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Updated {new Date(template.updated_at).toLocaleDateString()}
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2 pt-2">
|
||||
<Button variant="secondary" size="sm" className="flex-1">
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
View
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" className="flex-1">
|
||||
<Play className="h-4 w-4 mr-1" />
|
||||
Deploy
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredTemplates.length === 0 && !loading && (
|
||||
<div className="text-center py-12">
|
||||
<Bot className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">No templates found</h3>
|
||||
<p className="text-muted-foreground mb-4">
|
||||
Try adjusting your search criteria or create a new template.
|
||||
</p>
|
||||
<Button onClick={() => setShowCreateDialog(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Create Template
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react';
|
||||
|
||||
export default function Error({
|
||||
error,
|
||||
reset,
|
||||
}: {
|
||||
error: Error & { digest?: string };
|
||||
reset: () => void;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
console.error('Resources page error:', error);
|
||||
}, [error]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[600px] p-6">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 w-16 h-16 flex items-center justify-center rounded-full bg-red-100">
|
||||
<AlertTriangle className="w-8 h-8 text-red-600" />
|
||||
</div>
|
||||
<CardTitle className="text-red-600">Failed to load resources</CardTitle>
|
||||
<CardDescription>
|
||||
There was a problem loading the AI resources page. This could be due to a network issue or server error.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="text-sm text-muted-foreground bg-muted p-3 rounded-md">
|
||||
<strong>Error:</strong> {error.message}
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button onClick={reset} className="flex-1">
|
||||
<RefreshCw className="w-4 h-4 mr-2" />
|
||||
Try Again
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => window.location.reload()} className="flex-1">
|
||||
Reload Page
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { PageLoading } from '@/components/ui/loading';
|
||||
|
||||
export default function Loading() {
|
||||
return <PageLoading text="Loading AI resources..." />;
|
||||
}
|
||||
660
apps/control-panel-frontend/src/app/dashboard/resources/page.tsx
Normal file
660
apps/control-panel-frontend/src/app/dashboard/resources/page.tsx
Normal file
@@ -0,0 +1,660 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, Search, Edit, Trash2, Cpu, Loader2, TestTube2, Activity, Globe } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { resourcesApi } from '@/lib/api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface Resource {
|
||||
id: number;
|
||||
uuid: string;
|
||||
name: string;
|
||||
description: string;
|
||||
resource_type: string;
|
||||
provider: string;
|
||||
model_name: string;
|
||||
health_status: string;
|
||||
is_active: boolean;
|
||||
primary_endpoint: string;
|
||||
max_requests_per_minute: number;
|
||||
cost_per_1k_tokens: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
const RESOURCE_TYPES = [
|
||||
{ value: 'llm', label: 'Language Model' },
|
||||
{ value: 'embedding', label: 'Embedding Model' },
|
||||
{ value: 'vector_database', label: 'Vector Database' },
|
||||
{ value: 'document_processor', label: 'Document Processor' },
|
||||
{ value: 'agentic_workflow', label: 'Agent Workflow' },
|
||||
{ value: 'external_service', label: 'External Service' },
|
||||
];
|
||||
|
||||
const PROVIDERS = [
|
||||
{ value: 'groq', label: 'Groq' },
|
||||
{ value: 'openai', label: 'OpenAI' },
|
||||
{ value: 'anthropic', label: 'Anthropic' },
|
||||
{ value: 'cohere', label: 'Cohere' },
|
||||
{ value: 'local', label: 'Local' },
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
];
|
||||
|
||||
export default function ResourcesPage() {
|
||||
const [resources, setResources] = useState<Resource[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedType, setSelectedType] = useState<string>('all');
|
||||
const [selectedProvider, setSelectedProvider] = useState<string>('all');
|
||||
const [showCreateDialog, setShowCreateDialog] = useState(false);
|
||||
const [showEditDialog, setShowEditDialog] = useState(false);
|
||||
const [selectedResource, setSelectedResource] = useState<Resource | null>(null);
|
||||
const [isCreating, setIsCreating] = useState(false);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [isTesting, setIsTesting] = useState<number | null>(null);
|
||||
|
||||
// Form fields
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
description: '',
|
||||
resource_type: 'llm',
|
||||
provider: 'groq',
|
||||
model_name: '',
|
||||
primary_endpoint: '',
|
||||
max_requests_per_minute: 60,
|
||||
cost_per_1k_tokens: 0
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchResources();
|
||||
}, []);
|
||||
|
||||
const fetchResources = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Add timeout to prevent infinite loading
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 10000); // 10 second timeout
|
||||
|
||||
const response = await resourcesApi.list(1, 100);
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
setResources(response.data?.resources || response.data?.data?.resources || []);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch resources:', error);
|
||||
|
||||
// No fallback mock data - follow GT 2.0 "No Mocks" principle
|
||||
setResources([]);
|
||||
|
||||
if (error instanceof Error && error.name === 'AbortError') {
|
||||
toast.error('Request timed out - please try again');
|
||||
} else {
|
||||
toast.error('Failed to load resources - please check your connection');
|
||||
}
|
||||
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!formData.name || !formData.resource_type) {
|
||||
toast.error('Please fill in all required fields');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setIsCreating(true);
|
||||
await resourcesApi.create({
|
||||
...formData,
|
||||
api_endpoints: formData.primary_endpoint ? [formData.primary_endpoint] : [],
|
||||
failover_endpoints: [],
|
||||
configuration: {}
|
||||
});
|
||||
toast.success('Resource created successfully');
|
||||
setShowCreateDialog(false);
|
||||
setFormData({
|
||||
name: '',
|
||||
description: '',
|
||||
resource_type: 'llm',
|
||||
provider: 'groq',
|
||||
model_name: '',
|
||||
primary_endpoint: '',
|
||||
max_requests_per_minute: 60,
|
||||
cost_per_1k_tokens: 0
|
||||
});
|
||||
fetchResources();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create resource:', error);
|
||||
toast.error(error.response?.data?.detail || 'Failed to create resource');
|
||||
} finally {
|
||||
setIsCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!selectedResource) return;
|
||||
|
||||
try {
|
||||
setIsUpdating(true);
|
||||
await resourcesApi.update(selectedResource.id, {
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
max_requests_per_minute: formData.max_requests_per_minute,
|
||||
cost_per_1k_tokens: formData.cost_per_1k_tokens
|
||||
});
|
||||
toast.success('Resource updated successfully');
|
||||
setShowEditDialog(false);
|
||||
fetchResources();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update resource:', error);
|
||||
toast.error(error.response?.data?.detail || 'Failed to update resource');
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (resource: Resource) => {
|
||||
if (!confirm(`Are you sure you want to delete ${resource.name}?`)) return;
|
||||
|
||||
try {
|
||||
await resourcesApi.delete(resource.id);
|
||||
toast.success('Resource deleted successfully');
|
||||
fetchResources();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to delete resource:', error);
|
||||
toast.error(error.response?.data?.detail || 'Failed to delete resource');
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestConnection = async (resource: Resource) => {
|
||||
try {
|
||||
setIsTesting(resource.id);
|
||||
await resourcesApi.testConnection(resource.id);
|
||||
toast.success('Connection test successful');
|
||||
fetchResources(); // Refresh to get updated health status
|
||||
} catch (error: any) {
|
||||
console.error('Failed to test connection:', error);
|
||||
toast.error(error.response?.data?.detail || 'Connection test failed');
|
||||
} finally {
|
||||
setIsTesting(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openEditDialog = (resource: Resource) => {
|
||||
setSelectedResource(resource);
|
||||
setFormData({
|
||||
name: resource.name,
|
||||
description: resource.description || '',
|
||||
resource_type: resource.resource_type,
|
||||
provider: resource.provider,
|
||||
model_name: resource.model_name || '',
|
||||
primary_endpoint: resource.primary_endpoint || '',
|
||||
max_requests_per_minute: resource.max_requests_per_minute,
|
||||
cost_per_1k_tokens: resource.cost_per_1k_tokens
|
||||
});
|
||||
setShowEditDialog(true);
|
||||
};
|
||||
|
||||
const getHealthBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
return <Badge variant="default" className="bg-green-600">Healthy</Badge>;
|
||||
case 'unhealthy':
|
||||
return <Badge variant="destructive">Unhealthy</Badge>;
|
||||
case 'unknown':
|
||||
return <Badge variant="secondary">Unknown</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const getTypeBadge = (type: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
llm: 'bg-blue-600',
|
||||
embedding: 'bg-purple-600',
|
||||
vector_database: 'bg-orange-600',
|
||||
document_processor: 'bg-green-600',
|
||||
agentic_workflow: 'bg-indigo-600',
|
||||
external_service: 'bg-pink-600'
|
||||
};
|
||||
|
||||
return (
|
||||
<Badge variant="default" className={colors[type] || 'bg-gray-600'}>
|
||||
{type.replace('_', ' ').toUpperCase()}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const filteredResources = resources.filter(resource => {
|
||||
if (searchQuery && !resource.name.toLowerCase().includes(searchQuery.toLowerCase()) &&
|
||||
!resource.model_name?.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
if (selectedType !== 'all' && resource.resource_type !== selectedType) {
|
||||
return false;
|
||||
}
|
||||
if (selectedProvider !== 'all' && resource.provider !== selectedProvider) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[600px]">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<span className="text-muted-foreground">Loading resources...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">AI Resources</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage AI models, RAG engines, and external services
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowCreateDialog(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add Resource
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Total Resources</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{resources.length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Active</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{resources.filter(r => r.is_active).length}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Healthy</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">
|
||||
{resources.filter(r => r.health_status === 'healthy').length}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Unhealthy</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-600">
|
||||
{resources.filter(r => r.health_status === 'unhealthy').length}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle>Resource Catalog</CardTitle>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search resources..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery((e as React.ChangeEvent<HTMLInputElement>).target.value)}
|
||||
className="pl-8 w-[250px]"
|
||||
/>
|
||||
</div>
|
||||
<Select value={selectedType} onValueChange={setSelectedType}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="All Types" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
{RESOURCE_TYPES.map(type => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={selectedProvider} onValueChange={setSelectedProvider}>
|
||||
<SelectTrigger className="w-[130px]">
|
||||
<SelectValue placeholder="All Providers" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Providers</SelectItem>
|
||||
{PROVIDERS.map(provider => (
|
||||
<SelectItem key={provider.value} value={provider.value}>
|
||||
{provider.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{filteredResources.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Cpu className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">No resources found</p>
|
||||
<Button className="mt-4" onClick={() => setShowCreateDialog(true)}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Add your first resource
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Provider</TableHead>
|
||||
<TableHead>Model</TableHead>
|
||||
<TableHead>Health</TableHead>
|
||||
<TableHead>Rate Limit</TableHead>
|
||||
<TableHead>Cost</TableHead>
|
||||
<TableHead className="text-right">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredResources.map((resource) => (
|
||||
<TableRow key={resource.id}>
|
||||
<TableCell className="font-medium">{resource.name}</TableCell>
|
||||
<TableCell>{getTypeBadge(resource.resource_type)}</TableCell>
|
||||
<TableCell className="capitalize">{resource.provider}</TableCell>
|
||||
<TableCell>{resource.model_name || '-'}</TableCell>
|
||||
<TableCell>{getHealthBadge(resource.health_status)}</TableCell>
|
||||
<TableCell>{resource.max_requests_per_minute}/min</TableCell>
|
||||
<TableCell>${resource.cost_per_1k_tokens}/1K</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleTestConnection(resource)}
|
||||
disabled={isTesting === resource.id}
|
||||
title="Test Connection"
|
||||
>
|
||||
{isTesting === resource.id ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<TestTube2 className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => openEditDialog(resource)}
|
||||
title="Edit"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleDelete(resource)}
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create Dialog */}
|
||||
<Dialog open={showCreateDialog} onOpenChange={setShowCreateDialog}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add New Resource</DialogTitle>
|
||||
<DialogDescription>
|
||||
Configure a new AI resource for your platform
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">Resource Name *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: (e as React.ChangeEvent<HTMLInputElement>).target.value })}
|
||||
placeholder="GPT-4 Turbo"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="resource_type">Resource Type *</Label>
|
||||
<Select
|
||||
value={formData.resource_type}
|
||||
onValueChange={(value) => setFormData({ ...formData, resource_type: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{RESOURCE_TYPES.map(type => (
|
||||
<SelectItem key={type.value} value={type.value}>
|
||||
{type.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="provider">Provider *</Label>
|
||||
<Select
|
||||
value={formData.provider}
|
||||
onValueChange={(value) => setFormData({ ...formData, provider: value })}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{PROVIDERS.map(provider => (
|
||||
<SelectItem key={provider.value} value={provider.value}>
|
||||
{provider.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="model_name">Model Name</Label>
|
||||
<Input
|
||||
id="model_name"
|
||||
value={formData.model_name}
|
||||
onChange={(e) => setFormData({ ...formData, model_name: (e as React.ChangeEvent<HTMLInputElement>).target.value })}
|
||||
placeholder="gpt-4-turbo-preview"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">Description</Label>
|
||||
<Textarea
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
placeholder="Describe this resource..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="primary_endpoint">API Endpoint</Label>
|
||||
<Input
|
||||
id="primary_endpoint"
|
||||
value={formData.primary_endpoint}
|
||||
onChange={(e) => setFormData({ ...formData, primary_endpoint: (e as React.ChangeEvent<HTMLInputElement>).target.value })}
|
||||
placeholder="https://api.example.com/v1"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="max_requests">Rate Limit (req/min)</Label>
|
||||
<Input
|
||||
id="max_requests"
|
||||
type="number"
|
||||
value={formData.max_requests_per_minute}
|
||||
onChange={(e) => setFormData({ ...formData, max_requests_per_minute: parseInt((e as React.ChangeEvent<HTMLInputElement>).target.value) || 60 })}
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cost">Cost per 1K tokens ($)</Label>
|
||||
<Input
|
||||
id="cost"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={formData.cost_per_1k_tokens}
|
||||
onChange={(e) => setFormData({ ...formData, cost_per_1k_tokens: parseFloat((e as React.ChangeEvent<HTMLInputElement>).target.value) || 0 })}
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => setShowCreateDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleCreate} disabled={isCreating}>
|
||||
{isCreating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Creating...
|
||||
</>
|
||||
) : (
|
||||
'Create Resource'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Resource</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update resource configuration
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-name">Resource Name</Label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: (e as React.ChangeEvent<HTMLInputElement>).target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-description">Description</Label>
|
||||
<Textarea
|
||||
id="edit-description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-max_requests">Rate Limit (req/min)</Label>
|
||||
<Input
|
||||
id="edit-max_requests"
|
||||
type="number"
|
||||
value={formData.max_requests_per_minute}
|
||||
onChange={(e) => setFormData({ ...formData, max_requests_per_minute: parseInt((e as React.ChangeEvent<HTMLInputElement>).target.value) || 60 })}
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-cost">Cost per 1K tokens ($)</Label>
|
||||
<Input
|
||||
id="edit-cost"
|
||||
type="number"
|
||||
step="0.0001"
|
||||
value={formData.cost_per_1k_tokens}
|
||||
onChange={(e) => setFormData({ ...formData, cost_per_1k_tokens: parseFloat((e as React.ChangeEvent<HTMLInputElement>).target.value) || 0 })}
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => setShowEditDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUpdate} disabled={isUpdating}>
|
||||
{isUpdating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Updating...
|
||||
</>
|
||||
) : (
|
||||
'Update Resource'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
397
apps/control-panel-frontend/src/app/dashboard/security/page.tsx
Normal file
397
apps/control-panel-frontend/src/app/dashboard/security/page.tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Shield, Lock, AlertTriangle, UserCheck, Activity, FileText, Key, Loader2, Eye, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { securityApi } from '@/lib/api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface SecurityEvent {
|
||||
id: number;
|
||||
timestamp: string;
|
||||
event_type: string;
|
||||
severity: string;
|
||||
user: string;
|
||||
ip_address: string;
|
||||
description: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface AccessLog {
|
||||
id: number;
|
||||
timestamp: string;
|
||||
user_email: string;
|
||||
action: string;
|
||||
resource: string;
|
||||
result: string;
|
||||
ip_address: string;
|
||||
}
|
||||
|
||||
interface SecurityPolicy {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
status: string;
|
||||
last_updated: string;
|
||||
violations: number;
|
||||
}
|
||||
|
||||
export default function SecurityPage() {
|
||||
const [securityEvents, setSecurityEvents] = useState<SecurityEvent[]>([]);
|
||||
const [accessLogs, setAccessLogs] = useState<AccessLog[]>([]);
|
||||
const [policies, setPolicies] = useState<SecurityPolicy[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [selectedSeverity, setSelectedSeverity] = useState('all');
|
||||
const [selectedTimeRange, setSelectedTimeRange] = useState('24h');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchSecurityData();
|
||||
}, [selectedSeverity, selectedTimeRange]);
|
||||
|
||||
const fetchSecurityData = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
|
||||
// Fetch all security data in parallel
|
||||
const [eventsResponse, logsResponse, policiesResponse] = await Promise.all([
|
||||
securityApi.getSecurityEvents(1, 20, selectedSeverity === 'all' ? undefined : selectedSeverity, selectedTimeRange).catch(() => null),
|
||||
securityApi.getAccessLogs(1, 20, selectedTimeRange).catch(() => null),
|
||||
securityApi.getSecurityPolicies().catch(() => null)
|
||||
]);
|
||||
|
||||
// Set data from API responses or empty defaults
|
||||
setSecurityEvents(eventsResponse?.data?.events || []);
|
||||
setAccessLogs(logsResponse?.data?.access_logs || []);
|
||||
setPolicies(policiesResponse?.data?.policies || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch security data:', error);
|
||||
toast.error('Failed to load security data');
|
||||
|
||||
// Set empty arrays on error
|
||||
setSecurityEvents([]);
|
||||
setAccessLogs([]);
|
||||
setPolicies([]);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getSeverityBadge = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'critical':
|
||||
return <Badge variant="destructive">Critical</Badge>;
|
||||
case 'warning':
|
||||
return <Badge variant="default" className="bg-yellow-600">Warning</Badge>;
|
||||
case 'info':
|
||||
return <Badge variant="secondary">Info</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary">{severity}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'resolved':
|
||||
return <Badge variant="default" className="bg-green-600">Resolved</Badge>;
|
||||
case 'investigating':
|
||||
return <Badge variant="default" className="bg-blue-600">Investigating</Badge>;
|
||||
case 'acknowledged':
|
||||
return <Badge variant="default" className="bg-yellow-600">Acknowledged</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const getResultBadge = (result: string) => {
|
||||
return result === 'success' ? (
|
||||
<Badge variant="default" className="bg-green-600">Success</Badge>
|
||||
) : (
|
||||
<Badge variant="destructive">Denied</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const getEventIcon = (eventType: string) => {
|
||||
switch (eventType) {
|
||||
case 'login_attempt':
|
||||
return <UserCheck className="h-4 w-4" />;
|
||||
case 'permission_denied':
|
||||
return <Lock className="h-4 w-4" />;
|
||||
case 'brute_force_attempt':
|
||||
return <AlertTriangle className="h-4 w-4" />;
|
||||
case 'api_rate_limit':
|
||||
return <Activity className="h-4 w-4" />;
|
||||
default:
|
||||
return <Shield className="h-4 w-4" />;
|
||||
}
|
||||
};
|
||||
|
||||
const filteredEvents = securityEvents.filter(event => {
|
||||
if (selectedSeverity !== 'all' && event.severity !== selectedSeverity) {
|
||||
return false;
|
||||
}
|
||||
if (searchQuery && !event.description.toLowerCase().includes(searchQuery.toLowerCase()) &&
|
||||
!event.user.toLowerCase().includes(searchQuery.toLowerCase())) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[600px]">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<span className="text-muted-foreground">Loading security data...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Security Center</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Monitor security events, access logs, and policy compliance
|
||||
</p>
|
||||
</div>
|
||||
<Button variant="secondary">
|
||||
<FileText className="mr-2 h-4 w-4" />
|
||||
Export Report
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Security Overview Cards */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium flex items-center">
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
Security Score
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-green-600">92/100</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">Excellent</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium flex items-center">
|
||||
<AlertTriangle className="h-4 w-4 mr-2" />
|
||||
Active Threats
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-red-600">1</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">Requires attention</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium flex items-center">
|
||||
<UserCheck className="h-4 w-4 mr-2" />
|
||||
Failed Logins
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">3</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">Last 24 hours</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium flex items-center">
|
||||
<Lock className="h-4 w-4 mr-2" />
|
||||
Policy Violations
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-yellow-600">15</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">This week</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Security Events */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>Security Events</CardTitle>
|
||||
<CardDescription>Real-time security event monitoring</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Input
|
||||
placeholder="Search events..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery((e as React.ChangeEvent<HTMLInputElement>).target.value)}
|
||||
className="w-[200px]"
|
||||
/>
|
||||
<Select value={selectedSeverity} onValueChange={setSelectedSeverity}>
|
||||
<SelectTrigger className="w-[130px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Severities</SelectItem>
|
||||
<SelectItem value="critical">Critical</SelectItem>
|
||||
<SelectItem value="warning">Warning</SelectItem>
|
||||
<SelectItem value="info">Info</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={selectedTimeRange} onValueChange={setSelectedTimeRange}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1h">Last Hour</SelectItem>
|
||||
<SelectItem value="24h">Last 24h</SelectItem>
|
||||
<SelectItem value="7d">Last 7 Days</SelectItem>
|
||||
<SelectItem value="30d">Last 30 Days</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>Event</TableHead>
|
||||
<TableHead>Severity</TableHead>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>IP Address</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{filteredEvents.map((event) => (
|
||||
<TableRow key={event.id}>
|
||||
<TableCell className="text-sm">
|
||||
{new Date(event.timestamp).toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center space-x-2">
|
||||
{getEventIcon(event.event_type)}
|
||||
<span className="text-sm">{event.event_type.replace('_', ' ')}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{getSeverityBadge(event.severity)}</TableCell>
|
||||
<TableCell className="text-sm">{event.user}</TableCell>
|
||||
<TableCell className="text-sm font-mono">{event.ip_address}</TableCell>
|
||||
<TableCell className="text-sm">{event.description}</TableCell>
|
||||
<TableCell>{getStatusBadge(event.status)}</TableCell>
|
||||
<TableCell>
|
||||
<Button size="sm" variant="secondary">
|
||||
<Eye className="h-3 w-3" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
{/* Access Logs */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Access Logs</CardTitle>
|
||||
<CardDescription>Recent access attempts and API calls</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Time</TableHead>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Action</TableHead>
|
||||
<TableHead>Resource</TableHead>
|
||||
<TableHead>Result</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{accessLogs.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell className="text-sm">
|
||||
{new Date(log.timestamp).toLocaleTimeString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm">{log.user_email.split('@')[0]}</TableCell>
|
||||
<TableCell className="text-sm font-medium">{log.action}</TableCell>
|
||||
<TableCell className="text-sm font-mono text-xs">{log.resource}</TableCell>
|
||||
<TableCell>{getResultBadge(log.result)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Security Policies */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Security Policies</CardTitle>
|
||||
<CardDescription>Active security policies and compliance</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{policies.map((policy) => (
|
||||
<div key={policy.id} className="border rounded-lg p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Key className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{policy.name}</span>
|
||||
</div>
|
||||
{policy.status === 'active' ? (
|
||||
<CheckCircle className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-red-600" />
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-muted-foreground">Type: {policy.type.replace('_', ' ')}</span>
|
||||
{policy.violations > 0 && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
{policy.violations} violations
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
Updated {new Date(policy.last_updated).toLocaleDateString()}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import { Shield } from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { TFASettings } from '@/components/settings/tfa-settings';
|
||||
|
||||
export default function SettingsPage() {
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Settings</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your account settings and preferences
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Shield className="h-5 w-5 mr-2" />
|
||||
Security Settings
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Manage your account security and access controls
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-6">
|
||||
{/* Two-Factor Authentication Component */}
|
||||
<TFASettings />
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
400
apps/control-panel-frontend/src/app/dashboard/system/page.tsx
Normal file
400
apps/control-panel-frontend/src/app/dashboard/system/page.tsx
Normal file
@@ -0,0 +1,400 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Server, Database, HardDrive, Activity, CheckCircle, XCircle, AlertTriangle, Loader2, RefreshCw, Settings2, Cloud, Layers } from 'lucide-react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { systemApi } from '@/lib/api';
|
||||
import toast from 'react-hot-toast';
|
||||
import { BackupManager } from '@/components/system/BackupManager';
|
||||
import { UpdateModal } from '@/components/system/UpdateModal';
|
||||
|
||||
interface SystemHealth {
|
||||
overall_status: string;
|
||||
uptime: string;
|
||||
version: string;
|
||||
environment: string;
|
||||
}
|
||||
|
||||
interface ClusterInfo {
|
||||
name: string;
|
||||
status: string;
|
||||
nodes: number;
|
||||
pods: number;
|
||||
cpu_usage: number;
|
||||
memory_usage: number;
|
||||
storage_usage: number;
|
||||
}
|
||||
|
||||
interface ServiceStatus {
|
||||
name: string;
|
||||
status: string;
|
||||
health: string;
|
||||
version: string;
|
||||
uptime: string;
|
||||
last_check: string;
|
||||
}
|
||||
|
||||
interface SystemConfig {
|
||||
key: string;
|
||||
value: string;
|
||||
category: string;
|
||||
editable: boolean;
|
||||
}
|
||||
|
||||
interface SystemHealthDetailed {
|
||||
overall_status: string;
|
||||
containers: Array<{
|
||||
name: string;
|
||||
cluster: string;
|
||||
state: string;
|
||||
health: string;
|
||||
uptime: string;
|
||||
ports: string[];
|
||||
}>;
|
||||
clusters: Array<{
|
||||
name: string;
|
||||
healthy: number;
|
||||
unhealthy: number;
|
||||
total: number;
|
||||
}>;
|
||||
database: {
|
||||
connections_active: number;
|
||||
connections_max: number;
|
||||
cache_hit_ratio: number;
|
||||
database_size: string;
|
||||
transactions_committed: number;
|
||||
};
|
||||
version: string;
|
||||
}
|
||||
|
||||
interface UpdateInfo {
|
||||
current_version: string;
|
||||
latest_version: string;
|
||||
update_type: 'major' | 'minor' | 'patch';
|
||||
release_notes: string;
|
||||
released_at: string;
|
||||
}
|
||||
|
||||
export default function SystemPage() {
|
||||
const [systemHealth, setSystemHealth] = useState<SystemHealth | null>(null);
|
||||
const [healthData, setHealthData] = useState<SystemHealthDetailed | null>(null);
|
||||
const [clusters, setClusters] = useState<ClusterInfo[]>([]);
|
||||
const [services, setServices] = useState<ServiceStatus[]>([]);
|
||||
const [configs, setConfigs] = useState<SystemConfig[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [currentVersion, setCurrentVersion] = useState<string>('');
|
||||
const [isCheckingUpdate, setIsCheckingUpdate] = useState(false);
|
||||
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null);
|
||||
const [showUpdateModal, setShowUpdateModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchSystemData();
|
||||
}, []);
|
||||
|
||||
const fetchSystemData = async (showRefreshIndicator = false) => {
|
||||
try {
|
||||
if (showRefreshIndicator) {
|
||||
setIsRefreshing(true);
|
||||
} else {
|
||||
setIsLoading(true);
|
||||
}
|
||||
|
||||
// Fetch system data from API
|
||||
const [healthResponse, healthDetailedResponse] = await Promise.all([
|
||||
systemApi.health().catch(() => null),
|
||||
systemApi.healthDetailed().catch(() => null)
|
||||
]);
|
||||
|
||||
// Set system health from API response or defaults
|
||||
setSystemHealth({
|
||||
overall_status: healthDetailedResponse?.data?.overall_status || healthResponse?.data?.status || 'unknown',
|
||||
uptime: '0 days',
|
||||
version: healthDetailedResponse?.data?.version || '2.0.0',
|
||||
environment: 'development'
|
||||
});
|
||||
|
||||
// Set detailed health data for Database & Storage section
|
||||
if (healthDetailedResponse?.data) {
|
||||
setHealthData(healthDetailedResponse.data);
|
||||
}
|
||||
|
||||
// Clear clusters, services, configs - not used in current UI
|
||||
setClusters([]);
|
||||
setServices([]);
|
||||
setConfigs([]);
|
||||
|
||||
// Fetch version info
|
||||
try {
|
||||
const versionResponse = await systemApi.version();
|
||||
// Use either 'current_version' or 'version' field for compatibility
|
||||
setCurrentVersion(versionResponse.data.current_version || versionResponse.data.version || '2.0.0');
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch version:', error);
|
||||
}
|
||||
|
||||
if (showRefreshIndicator) {
|
||||
toast.success('System data refreshed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch system data:', error);
|
||||
toast.error('Failed to load system data');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusIcon = (status: string) => {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
case 'running':
|
||||
return <CheckCircle className="h-5 w-5 text-green-600" />;
|
||||
case 'warning':
|
||||
return <AlertTriangle className="h-5 w-5 text-yellow-600" />;
|
||||
case 'unhealthy':
|
||||
case 'stopped':
|
||||
return <XCircle className="h-5 w-5 text-red-600" />;
|
||||
default:
|
||||
return <Activity className="h-5 w-5 text-gray-600" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
case 'running':
|
||||
return <Badge variant="default" className="bg-green-600">Healthy</Badge>;
|
||||
case 'warning':
|
||||
return <Badge variant="default" className="bg-yellow-600">Warning</Badge>;
|
||||
case 'unhealthy':
|
||||
case 'stopped':
|
||||
return <Badge variant="destructive">Unhealthy</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const getUsageColor = (value: number) => {
|
||||
if (value > 80) return 'bg-red-600';
|
||||
if (value > 60) return 'bg-yellow-600';
|
||||
return 'bg-green-600';
|
||||
};
|
||||
|
||||
const handleCheckForUpdates = async () => {
|
||||
setIsCheckingUpdate(true);
|
||||
try {
|
||||
const response = await systemApi.checkUpdate();
|
||||
if (response.data.update_available) {
|
||||
// Map backend response to UpdateInfo format
|
||||
const info: UpdateInfo = {
|
||||
current_version: response.data.current_version,
|
||||
latest_version: response.data.latest_version,
|
||||
update_type: response.data.update_type || 'patch',
|
||||
release_notes: response.data.release_notes || '',
|
||||
released_at: response.data.released_at || response.data.published_at || ''
|
||||
};
|
||||
setUpdateInfo(info);
|
||||
setShowUpdateModal(true);
|
||||
toast.success(`Update available: v${response.data.latest_version}`);
|
||||
} else {
|
||||
toast.success('System is up to date');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to check for updates:', error);
|
||||
toast.error('Failed to check for updates');
|
||||
} finally {
|
||||
setIsCheckingUpdate(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdateModalClose = () => {
|
||||
setShowUpdateModal(false);
|
||||
// Refresh system data after modal closes (in case update was performed)
|
||||
fetchSystemData();
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[600px]">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<span className="text-muted-foreground">Loading system information...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">System Management</h1>
|
||||
<p className="text-muted-foreground">
|
||||
System health, cluster status, and configuration
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => fetchSystemData(true)}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
{isRefreshing ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Refreshing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Refresh
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* System Overview */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">System Status</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center space-x-2">
|
||||
{getStatusIcon(systemHealth?.overall_status || 'unknown')}
|
||||
<span className="text-2xl font-bold capitalize">
|
||||
{systemHealth?.overall_status || 'Unknown'}
|
||||
</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Uptime</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{systemHealth?.uptime || '-'}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Version</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{systemHealth?.version || '-'}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-sm font-medium">Environment</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold capitalize">{systemHealth?.environment || '-'}</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Software Updates */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Cloud className="h-5 w-5 mr-2" />
|
||||
Software Updates
|
||||
</CardTitle>
|
||||
<CardDescription>Manage system software updates</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-muted-foreground">Current Version</div>
|
||||
<div className="text-2xl font-bold">{currentVersion || systemHealth?.version || '-'}</div>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<Button
|
||||
onClick={handleCheckForUpdates}
|
||||
disabled={isCheckingUpdate}
|
||||
className="w-full"
|
||||
>
|
||||
{isCheckingUpdate ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Checking...
|
||||
</>
|
||||
) : (
|
||||
'Check for Updates'
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Backup Management */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<HardDrive className="h-5 w-5 mr-2" />
|
||||
Backup Management
|
||||
</CardTitle>
|
||||
<CardDescription>Create and restore system backups</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<BackupManager />
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Database Status */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center">
|
||||
<Database className="h-5 w-5 mr-2" />
|
||||
Database & Storage
|
||||
</CardTitle>
|
||||
<CardDescription>Database connections and storage metrics</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-muted-foreground">PostgreSQL Connections</div>
|
||||
<div className="text-2xl font-bold">
|
||||
{healthData?.database?.connections_active ?? '-'} / {healthData?.database?.connections_max ?? '-'}
|
||||
</div>
|
||||
<Progress value={healthData?.database ? (healthData.database.connections_active / healthData.database.connections_max) * 100 : 0} className="h-2" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-muted-foreground">Cache Hit Ratio</div>
|
||||
<div className="text-2xl font-bold">{healthData?.database?.cache_hit_ratio ?? '-'}%</div>
|
||||
<Progress value={healthData?.database?.cache_hit_ratio ?? 0} className="h-2" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm text-muted-foreground">Database Size</div>
|
||||
<div className="text-2xl font-bold">{healthData?.database?.database_size ?? '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Update Modal */}
|
||||
{updateInfo && (
|
||||
<UpdateModal
|
||||
updateInfo={updateInfo}
|
||||
open={showUpdateModal}
|
||||
onClose={handleUpdateModalClose}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
209
apps/control-panel-frontend/src/app/dashboard/templates/page.tsx
Normal file
209
apps/control-panel-frontend/src/app/dashboard/templates/page.tsx
Normal file
@@ -0,0 +1,209 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
FileText,
|
||||
Download,
|
||||
Upload,
|
||||
Trash2,
|
||||
Plus,
|
||||
Loader2,
|
||||
CheckCircle
|
||||
} from 'lucide-react';
|
||||
import { useToast } from '@/components/ui/use-toast';
|
||||
import { TemplatePreview } from '@/components/templates/TemplatePreview';
|
||||
import { ApplyTemplateModal } from '@/components/templates/ApplyTemplateModal';
|
||||
import { ExportTemplateModal } from '@/components/templates/ExportTemplateModal';
|
||||
|
||||
interface Template {
|
||||
id: number;
|
||||
name: string;
|
||||
description: string;
|
||||
is_default: boolean;
|
||||
resource_counts: {
|
||||
models: number;
|
||||
agents: number;
|
||||
datasets: number;
|
||||
};
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export default function TemplatesPage() {
|
||||
const { toast } = useToast();
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<Template | null>(null);
|
||||
const [showApplyModal, setShowApplyModal] = useState(false);
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTemplates();
|
||||
}, []);
|
||||
|
||||
const fetchTemplates = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await fetch('/api/v1/templates/');
|
||||
if (!response.ok) throw new Error('Failed to fetch templates');
|
||||
|
||||
const data = await response.json();
|
||||
setTemplates(data);
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to load templates",
|
||||
variant: "destructive"
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleApplyTemplate = (template: Template) => {
|
||||
setSelectedTemplate(template);
|
||||
setShowApplyModal(true);
|
||||
};
|
||||
|
||||
const handleDeleteTemplate = async (templateId: number, templateName: string) => {
|
||||
if (!confirm(`Are you sure you want to delete template "${templateName}"?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/v1/templates/${templateId}`, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to delete template');
|
||||
|
||||
toast({
|
||||
title: "Success",
|
||||
description: `Template "${templateName}" deleted successfully`
|
||||
});
|
||||
|
||||
fetchTemplates();
|
||||
} catch (error) {
|
||||
toast({
|
||||
title: "Error",
|
||||
description: "Failed to delete template",
|
||||
variant: "destructive"
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const onApplySuccess = () => {
|
||||
setShowApplyModal(false);
|
||||
setSelectedTemplate(null);
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Template applied successfully"
|
||||
});
|
||||
};
|
||||
|
||||
const onExportSuccess = () => {
|
||||
setShowExportModal(false);
|
||||
fetchTemplates();
|
||||
toast({
|
||||
title: "Success",
|
||||
description: "Template exported successfully"
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Tenant Templates</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
Manage and apply configuration templates to tenants
|
||||
</p>
|
||||
</div>
|
||||
<Button onClick={() => setShowExportModal(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Export Current Tenant
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
) : templates.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12">
|
||||
<div className="text-center text-muted-foreground">
|
||||
<FileText className="h-12 w-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg">No templates found</p>
|
||||
<p className="text-sm mt-1">Export your current tenant to create a template</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{templates.map((template) => (
|
||||
<Card key={template.id} className="hover:shadow-lg transition-shadow">
|
||||
<CardHeader>
|
||||
<div className="flex justify-between items-start">
|
||||
<div>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
{template.name}
|
||||
{template.is_default && (
|
||||
<Badge variant="default">Default</Badge>
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription className="mt-1">
|
||||
{template.description || 'No description'}
|
||||
</CardDescription>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<TemplatePreview template={template} />
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={() => handleApplyTemplate(template)}
|
||||
className="flex-1"
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Apply
|
||||
</Button>
|
||||
{!template.is_default && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => handleDeleteTemplate(template.id, template.name)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedTemplate && (
|
||||
<ApplyTemplateModal
|
||||
open={showApplyModal}
|
||||
onClose={() => {
|
||||
setShowApplyModal(false);
|
||||
setSelectedTemplate(null);
|
||||
}}
|
||||
template={selectedTemplate}
|
||||
onSuccess={onApplySuccess}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ExportTemplateModal
|
||||
open={showExportModal}
|
||||
onClose={() => setShowExportModal(false)}
|
||||
onSuccess={onExportSuccess}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
283
apps/control-panel-frontend/src/app/dashboard/tenants/page.tsx
Normal file
283
apps/control-panel-frontend/src/app/dashboard/tenants/page.tsx
Normal file
@@ -0,0 +1,283 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Edit, Building2, Loader2, Power } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { tenantsApi } from '@/lib/api';
|
||||
import toast from 'react-hot-toast';
|
||||
|
||||
interface Tenant {
|
||||
id: number;
|
||||
uuid: string;
|
||||
name: string;
|
||||
domain: string;
|
||||
template: string;
|
||||
status: string;
|
||||
max_users: number;
|
||||
user_count: number;
|
||||
resource_limits: any;
|
||||
namespace: string;
|
||||
frontend_url?: string;
|
||||
optics_enabled?: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
// Budget configuration
|
||||
monthly_budget_cents?: number | null;
|
||||
budget_warning_threshold?: number | null;
|
||||
budget_critical_threshold?: number | null;
|
||||
budget_enforcement_enabled?: boolean | null;
|
||||
// Storage pricing - Hot tier only
|
||||
storage_price_dataset_hot?: number | null;
|
||||
storage_price_conversation_hot?: number | null;
|
||||
// Cold tier allocation-based
|
||||
cold_storage_allocated_tibs?: number | null;
|
||||
cold_storage_price_per_tib?: number | null;
|
||||
}
|
||||
|
||||
export default function TenantsPage() {
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showEditDialog, setShowEditDialog] = useState(false);
|
||||
const [selectedTenant, setSelectedTenant] = useState<Tenant | null>(null);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
|
||||
// Form fields (simplified for Community Edition)
|
||||
const [formData, setFormData] = useState({
|
||||
name: '',
|
||||
frontend_url: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchTenants();
|
||||
}, []);
|
||||
|
||||
const fetchTenants = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const response = await tenantsApi.list(1, 100);
|
||||
setTenants(response.data.tenants || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch tenants:', error);
|
||||
toast.error('Failed to load tenant');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUpdate = async () => {
|
||||
if (!selectedTenant) return;
|
||||
|
||||
try {
|
||||
setIsUpdating(true);
|
||||
|
||||
await tenantsApi.update(selectedTenant.id, {
|
||||
name: formData.name,
|
||||
frontend_url: formData.frontend_url,
|
||||
});
|
||||
toast.success('Tenant updated successfully');
|
||||
setShowEditDialog(false);
|
||||
fetchTenants();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update tenant:', error);
|
||||
toast.error(error.response?.data?.detail || 'Failed to update tenant');
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeploy = async (tenant: Tenant) => {
|
||||
try {
|
||||
await tenantsApi.deploy(tenant.id);
|
||||
toast.success('Deployment initiated for ' + tenant.name);
|
||||
fetchTenants();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to deploy tenant:', error);
|
||||
toast.error(error.response?.data?.detail || 'Failed to deploy tenant');
|
||||
}
|
||||
};
|
||||
|
||||
const openEditDialog = (tenant: Tenant) => {
|
||||
setSelectedTenant(tenant);
|
||||
setFormData({
|
||||
...formData,
|
||||
name: tenant.name,
|
||||
frontend_url: tenant.frontend_url || '',
|
||||
});
|
||||
setShowEditDialog(true);
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return <Badge variant="default" className="bg-green-600">Active</Badge>;
|
||||
case 'pending':
|
||||
return <Badge variant="secondary">Pending</Badge>;
|
||||
case 'suspended':
|
||||
return <Badge variant="destructive">Suspended</Badge>;
|
||||
case 'deploying':
|
||||
return <Badge variant="secondary">Deploying</Badge>;
|
||||
case 'archived':
|
||||
return <Badge variant="secondary">Archived</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[600px]">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Loader2 className="h-6 w-6 animate-spin" />
|
||||
<span className="text-muted-foreground">Loading tenant...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Tenant</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage your tenant configuration
|
||||
</p>
|
||||
<p className="text-sm text-amber-600 mt-1">
|
||||
GT AI OS Community Edition: Limited to 50 users per tenant
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tenants.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-12">
|
||||
<div className="text-center">
|
||||
<Building2 className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
||||
<p className="text-muted-foreground">No tenant configured</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
tenants.map((tenant) => (
|
||||
<Card key={tenant.id}>
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-3">
|
||||
<Building2 className="h-6 w-6 text-muted-foreground" />
|
||||
<div>
|
||||
<CardTitle>{tenant.name}</CardTitle>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{tenant.frontend_url || 'http://localhost:3002'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
{getStatusBadge(tenant.status)}
|
||||
{tenant.status === 'pending' && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleDeploy(tenant)}
|
||||
>
|
||||
<Power className="h-4 w-4 mr-2" />
|
||||
Deploy
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => openEditDialog(tenant)}
|
||||
>
|
||||
<Edit className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Users</p>
|
||||
<p className="text-2xl font-bold">{tenant.user_count} <span className="text-sm font-normal text-muted-foreground">/ 50</span></p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Domain</p>
|
||||
<p className="text-lg font-medium">{tenant.domain}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Created</p>
|
||||
<p className="text-lg font-medium">{new Date(tenant.created_at).toLocaleDateString()}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Status</p>
|
||||
<p className="text-lg font-medium capitalize">{tenant.status}</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
|
||||
{/* Edit Dialog */}
|
||||
<Dialog open={showEditDialog} onOpenChange={setShowEditDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Tenant</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update tenant configuration
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-name">Tenant Name</Label>
|
||||
<Input
|
||||
id="edit-name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData({ ...formData, name: (e as React.ChangeEvent<HTMLInputElement>).target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="edit-frontend_url">Frontend URL (Optional)</Label>
|
||||
<Input
|
||||
id="edit-frontend_url"
|
||||
value={formData.frontend_url}
|
||||
onChange={(e) => setFormData({ ...formData, frontend_url: (e as React.ChangeEvent<HTMLInputElement>).target.value })}
|
||||
placeholder="https://app.company.com or http://localhost:3002"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Custom frontend URL for this tenant. Leave blank to use http://localhost:3002
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="secondary" onClick={() => setShowEditDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUpdate} disabled={isUpdating}>
|
||||
{isUpdating ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Updating...
|
||||
</>
|
||||
) : (
|
||||
'Update Tenant'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
646
apps/control-panel-frontend/src/app/dashboard/users/page.tsx
Normal file
646
apps/control-panel-frontend/src/app/dashboard/users/page.tsx
Normal file
@@ -0,0 +1,646 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { usersApi } from '@/lib/api';
|
||||
import toast from 'react-hot-toast';
|
||||
import AddUserDialog from '@/components/users/AddUserDialog';
|
||||
import EditUserDialog from '@/components/users/EditUserDialog';
|
||||
import DeleteUserDialog from '@/components/users/DeleteUserDialog';
|
||||
import BulkUploadDialog from '@/components/users/BulkUploadDialog';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
Users,
|
||||
User,
|
||||
Shield,
|
||||
Key,
|
||||
Building2,
|
||||
Activity,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Settings,
|
||||
Eye,
|
||||
Mail,
|
||||
Calendar,
|
||||
Clock,
|
||||
MoreVertical,
|
||||
UserCog,
|
||||
ShieldCheck,
|
||||
Lock,
|
||||
Edit,
|
||||
Trash2,
|
||||
Upload,
|
||||
RotateCcw,
|
||||
ShieldOff,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface UserType {
|
||||
id: number;
|
||||
email: string;
|
||||
full_name: string;
|
||||
user_type: 'super_admin' | 'tenant_admin' | 'tenant_user';
|
||||
tenant_id?: number;
|
||||
tenant_name?: string;
|
||||
status: 'active' | 'inactive' | 'suspended';
|
||||
capabilities: string[];
|
||||
access_groups: string[];
|
||||
last_login?: string;
|
||||
created_at: string;
|
||||
tfa_enabled?: boolean;
|
||||
tfa_required?: boolean;
|
||||
tfa_status?: 'disabled' | 'enabled' | 'enforced';
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const [users, setUsers] = useState<UserType[]>([]);
|
||||
const [filteredUsers, setFilteredUsers] = useState<UserType[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState('all');
|
||||
const [selectedUsers, setSelectedUsers] = useState<Set<number>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [totalUsers, setTotalUsers] = useState(0);
|
||||
const [limit] = useState(20);
|
||||
const [searchInput, setSearchInput] = useState('');
|
||||
const [roleCounts, setRoleCounts] = useState({
|
||||
super_admin: 0,
|
||||
tenant_admin: 0,
|
||||
tenant_user: 0,
|
||||
});
|
||||
|
||||
// Dialog states
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [bulkUploadDialogOpen, setBulkUploadDialogOpen] = useState(false);
|
||||
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
|
||||
const [userToDelete, setUserToDelete] = useState<{
|
||||
id: number;
|
||||
email: string;
|
||||
full_name: string;
|
||||
user_type: string;
|
||||
} | null>(null);
|
||||
|
||||
// Fetch role counts on mount
|
||||
useEffect(() => {
|
||||
fetchRoleCounts();
|
||||
}, []);
|
||||
|
||||
// Fetch real users from API - GT 2.0 "No Mocks" principle
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, [currentPage, searchQuery, typeFilter]);
|
||||
|
||||
const fetchRoleCounts = async () => {
|
||||
try {
|
||||
// Fetch counts for each role
|
||||
const [superAdminRes, tenantAdminRes, tenantUserRes] = await Promise.all([
|
||||
usersApi.list(1, 1, undefined, undefined, 'super_admin'),
|
||||
usersApi.list(1, 1, undefined, undefined, 'tenant_admin'),
|
||||
usersApi.list(1, 1, undefined, undefined, 'tenant_user'),
|
||||
]);
|
||||
|
||||
setRoleCounts({
|
||||
super_admin: superAdminRes.data?.total || 0,
|
||||
tenant_admin: tenantAdminRes.data?.total || 0,
|
||||
tenant_user: tenantUserRes.data?.total || 0,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch role counts:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await usersApi.list(
|
||||
currentPage,
|
||||
limit,
|
||||
searchQuery || undefined,
|
||||
undefined,
|
||||
typeFilter !== 'all' ? typeFilter : undefined
|
||||
);
|
||||
const userData = response.data?.users || response.data?.data || [];
|
||||
setTotalUsers(response.data?.total || 0);
|
||||
|
||||
// Map API response to expected format
|
||||
const mappedUsers: UserType[] = userData.map((user: any) => ({
|
||||
...user,
|
||||
status: user.is_active ? 'active' : 'suspended',
|
||||
capabilities: user.capabilities || [],
|
||||
access_groups: user.access_groups || [],
|
||||
}));
|
||||
|
||||
setUsers(mappedUsers);
|
||||
setFilteredUsers(mappedUsers);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
toast.error('Failed to load users');
|
||||
setUsers([]);
|
||||
setFilteredUsers([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSearch = () => {
|
||||
setSearchQuery(searchInput);
|
||||
setCurrentPage(1); // Reset to first page on new search
|
||||
};
|
||||
|
||||
const handleSearchKeyPress = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleSearch();
|
||||
}
|
||||
};
|
||||
|
||||
const handleTypeFilterChange = (newFilter: string) => {
|
||||
setTypeFilter(newFilter);
|
||||
setCurrentPage(1); // Reset to first page on filter change
|
||||
};
|
||||
|
||||
const handleSelectAll = async () => {
|
||||
// Fetch all user IDs with current filters
|
||||
try {
|
||||
const response = await usersApi.list(
|
||||
1,
|
||||
totalUsers, // Get all users
|
||||
searchQuery || undefined,
|
||||
undefined,
|
||||
typeFilter !== 'all' ? typeFilter : undefined
|
||||
);
|
||||
const allUserIds = response.data?.users?.map((u: any) => u.id) || [];
|
||||
setSelectedUsers(new Set(allUserIds));
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch all users:', error);
|
||||
toast.error('Failed to select all users');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteSelected = async () => {
|
||||
if (selectedUsers.size === 0) return;
|
||||
|
||||
const confirmMessage = `Are you sure you want to permanently delete ${selectedUsers.size} user${selectedUsers.size > 1 ? 's' : ''}? This action cannot be undone.`;
|
||||
|
||||
if (!confirm(confirmMessage)) return;
|
||||
|
||||
try {
|
||||
// Delete each selected user
|
||||
const deletePromises = Array.from(selectedUsers).map(userId =>
|
||||
usersApi.delete(userId)
|
||||
);
|
||||
|
||||
await Promise.all(deletePromises);
|
||||
|
||||
toast.success(`Successfully deleted ${selectedUsers.size} user${selectedUsers.size > 1 ? 's' : ''}`);
|
||||
setSelectedUsers(new Set());
|
||||
fetchUsers(); // Reload the user list
|
||||
fetchRoleCounts(); // Update role counts
|
||||
} catch (error) {
|
||||
console.error('Failed to delete users:', error);
|
||||
toast.error('Failed to delete some users');
|
||||
fetchUsers(); // Reload to show which users were actually deleted
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetTFA = async () => {
|
||||
if (selectedUsers.size === 0) return;
|
||||
|
||||
const confirmMessage = `Reset 2FA for ${selectedUsers.size} user${selectedUsers.size > 1 ? 's' : ''}? They will need to set up 2FA again if required.`;
|
||||
|
||||
if (!confirm(confirmMessage)) return;
|
||||
|
||||
try {
|
||||
const userIds = Array.from(selectedUsers);
|
||||
const response = await usersApi.bulkResetTFA(userIds);
|
||||
const result = response.data;
|
||||
|
||||
if (result.failed_count > 0) {
|
||||
toast.error(`Reset 2FA for ${result.success_count} users, ${result.failed_count} failed`);
|
||||
} else {
|
||||
toast.success(`Successfully reset 2FA for ${result.success_count} user${result.success_count > 1 ? 's' : ''}`);
|
||||
}
|
||||
|
||||
setSelectedUsers(new Set());
|
||||
fetchUsers();
|
||||
} catch (error) {
|
||||
console.error('Failed to reset 2FA:', error);
|
||||
toast.error('Failed to reset 2FA');
|
||||
}
|
||||
};
|
||||
|
||||
const handleEnforceTFA = async () => {
|
||||
if (selectedUsers.size === 0) return;
|
||||
|
||||
const confirmMessage = `Enforce 2FA for ${selectedUsers.size} user${selectedUsers.size > 1 ? 's' : ''}? They will be required to set up 2FA on next login.`;
|
||||
|
||||
if (!confirm(confirmMessage)) return;
|
||||
|
||||
try {
|
||||
const userIds = Array.from(selectedUsers);
|
||||
const response = await usersApi.bulkEnforceTFA(userIds);
|
||||
const result = response.data;
|
||||
|
||||
if (result.failed_count > 0) {
|
||||
toast.error(`Enforced 2FA for ${result.success_count} users, ${result.failed_count} failed`);
|
||||
} else {
|
||||
toast.success(`Successfully enforced 2FA for ${result.success_count} user${result.success_count > 1 ? 's' : ''}`);
|
||||
}
|
||||
|
||||
setSelectedUsers(new Set());
|
||||
fetchUsers();
|
||||
} catch (error) {
|
||||
console.error('Failed to enforce 2FA:', error);
|
||||
toast.error('Failed to enforce 2FA');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDisableTFA = async () => {
|
||||
if (selectedUsers.size === 0) return;
|
||||
|
||||
const confirmMessage = `Disable 2FA requirement for ${selectedUsers.size} user${selectedUsers.size > 1 ? 's' : ''}?`;
|
||||
|
||||
if (!confirm(confirmMessage)) return;
|
||||
|
||||
try {
|
||||
const userIds = Array.from(selectedUsers);
|
||||
const response = await usersApi.bulkDisableTFA(userIds);
|
||||
const result = response.data;
|
||||
|
||||
if (result.failed_count > 0) {
|
||||
toast.error(`Disabled 2FA for ${result.success_count} users, ${result.failed_count} failed`);
|
||||
} else {
|
||||
toast.success(`Successfully disabled 2FA requirement for ${result.success_count} user${result.success_count > 1 ? 's' : ''}`);
|
||||
}
|
||||
|
||||
setSelectedUsers(new Set());
|
||||
fetchUsers();
|
||||
} catch (error) {
|
||||
console.error('Failed to disable 2FA:', error);
|
||||
toast.error('Failed to disable 2FA requirement');
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return <Badge variant="default" className="bg-green-600"><CheckCircle className="h-3 w-3 mr-1" />Active</Badge>;
|
||||
case 'inactive':
|
||||
return <Badge variant="secondary"><Clock className="h-3 w-3 mr-1" />Inactive</Badge>;
|
||||
case 'suspended':
|
||||
return <Badge variant="destructive"><XCircle className="h-3 w-3 mr-1" />Suspended</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const getUserTypeBadge = (type: string) => {
|
||||
switch (type) {
|
||||
case 'super_admin':
|
||||
return <Badge className="bg-purple-600"><ShieldCheck className="h-3 w-3 mr-1" />Super Admin</Badge>;
|
||||
case 'tenant_admin':
|
||||
return <Badge className="bg-blue-600"><UserCog className="h-3 w-3 mr-1" />Tenant Admin</Badge>;
|
||||
case 'tenant_user':
|
||||
return <Badge variant="secondary"><User className="h-3 w-3 mr-1" />User</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary">{type}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const typeTabs = [
|
||||
{ id: 'all', label: 'All Users', count: roleCounts.super_admin + roleCounts.tenant_admin + roleCounts.tenant_user },
|
||||
{ id: 'super_admin', label: 'Super Admins', count: roleCounts.super_admin },
|
||||
{ id: 'tenant_admin', label: 'Tenant Admins', count: roleCounts.tenant_admin },
|
||||
{ id: 'tenant_user', label: 'Tenant Users', count: roleCounts.tenant_user },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Users</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage users and access permissions
|
||||
</p>
|
||||
<p className="text-sm text-amber-600 mt-1">
|
||||
GT AI OS Community Edition: Limited to 50 users
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="secondary" onClick={() => setBulkUploadDialogOpen(true)}>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Bulk Upload
|
||||
</Button>
|
||||
<Button onClick={() => setAddDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add User
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-1 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{roleCounts.super_admin + roleCounts.tenant_admin + roleCounts.tenant_user}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{users.filter(u => u.status === 'active').length} active on this page
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Type Tabs */}
|
||||
<div className="flex space-x-2 border-b">
|
||||
{typeTabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => handleTypeFilterChange(tab.id)}
|
||||
className={`px-4 py-2 border-b-2 transition-colors ${
|
||||
typeFilter === tab.id
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
<Badge variant="secondary" className="ml-2">{tab.count}</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="flex space-x-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search users by name, email, or tenant... (Press Enter)"
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput((e as React.ChangeEvent<HTMLInputElement>).target.value)}
|
||||
onKeyPress={handleSearchKeyPress}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="secondary" onClick={handleSearch}>
|
||||
<Search className="h-4 w-4 mr-2" />
|
||||
Search
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Bulk Actions */}
|
||||
{selectedUsers.size > 0 && (
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="flex items-center justify-between py-3">
|
||||
<span className="text-sm">
|
||||
{selectedUsers.size} user{selectedUsers.size > 1 ? 's' : ''} selected
|
||||
</span>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={handleResetTFA}>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
Reset 2FA
|
||||
</Button>
|
||||
<Button variant="default" size="sm" onClick={handleEnforceTFA}>
|
||||
<ShieldCheck className="h-4 w-4 mr-2" />
|
||||
Enforce 2FA
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={handleDisableTFA}>
|
||||
<ShieldOff className="h-4 w-4 mr-2" />
|
||||
Disable 2FA
|
||||
</Button>
|
||||
<Button variant="destructive" size="sm" onClick={handleDeleteSelected}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete Selected
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Users Table */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Activity className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="border-b bg-muted/50">
|
||||
<tr>
|
||||
<th className="p-4 text-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
handleSelectAll();
|
||||
} else {
|
||||
setSelectedUsers(new Set());
|
||||
}
|
||||
}}
|
||||
checked={selectedUsers.size > 0 && selectedUsers.size === totalUsers}
|
||||
/>
|
||||
</th>
|
||||
<th className="p-4 text-left font-medium">User</th>
|
||||
<th className="p-4 text-left font-medium">Type</th>
|
||||
<th className="p-4 text-left font-medium">Tenant</th>
|
||||
<th className="p-4 text-left font-medium">Status</th>
|
||||
<th className="p-4 text-left font-medium">2FA</th>
|
||||
<th className="p-4 text-left font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredUsers.map(user => (
|
||||
<tr key={user.id} className="border-b hover:bg-muted/30">
|
||||
<td className="p-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedUsers.has(user.id)}
|
||||
onChange={(e) => {
|
||||
const newSelected = new Set(selectedUsers);
|
||||
if (e.target.checked) {
|
||||
newSelected.add(user.id);
|
||||
} else {
|
||||
newSelected.delete(user.id);
|
||||
}
|
||||
setSelectedUsers(newSelected);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div>
|
||||
<div className="font-medium">{user.full_name}</div>
|
||||
<div className="text-sm text-muted-foreground flex items-center space-x-1">
|
||||
<Mail className="h-3 w-3" />
|
||||
<span>{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{getUserTypeBadge(user.user_type)}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{user.tenant_name ? (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Building2 className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{user.tenant_name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">System</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{getStatusBadge(user.status)}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{user.tfa_required && user.tfa_enabled ? (
|
||||
<Badge variant="default" className="bg-green-600">
|
||||
<ShieldCheck className="h-3 w-3 mr-1" />
|
||||
Enforced & Configured
|
||||
</Badge>
|
||||
) : user.tfa_required && !user.tfa_enabled ? (
|
||||
<Badge variant="default" className="bg-orange-500">
|
||||
<AlertTriangle className="h-3 w-3 mr-1" />
|
||||
Enforced (Pending)
|
||||
</Badge>
|
||||
) : !user.tfa_required && user.tfa_enabled ? (
|
||||
<Badge variant="default" className="bg-green-500">
|
||||
<ShieldCheck className="h-3 w-3 mr-1" />
|
||||
Enabled
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary">
|
||||
<Lock className="h-3 w-3 mr-1" />
|
||||
Disabled
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div className="flex space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedUserId(user.id);
|
||||
setEditDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setUserToDelete({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
full_name: user.full_name,
|
||||
user_type: user.user_type,
|
||||
});
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Pagination */}
|
||||
{!loading && totalUsers > 0 && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing {((currentPage - 1) * limit) + 1} to {Math.min(currentPage * limit, totalUsers)} of {totalUsers} users
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
|
||||
disabled={currentPage === 1}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
<div className="flex items-center space-x-1">
|
||||
{Array.from({ length: Math.ceil(totalUsers / limit) }, (_, i) => i + 1)
|
||||
.filter(page => {
|
||||
// Show first page, last page, current page, and pages around current
|
||||
const totalPages = Math.ceil(totalUsers / limit);
|
||||
return page === 1 ||
|
||||
page === totalPages ||
|
||||
(page >= currentPage - 1 && page <= currentPage + 1);
|
||||
})
|
||||
.map((page, index, array) => {
|
||||
// Add ellipsis if there's a gap
|
||||
const showEllipsisBefore = index > 0 && page - array[index - 1] > 1;
|
||||
return (
|
||||
<div key={page} className="flex items-center">
|
||||
{showEllipsisBefore && <span className="px-2">...</span>}
|
||||
<Button
|
||||
variant={currentPage === page ? "default" : "ghost"}
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(page)}
|
||||
className="min-w-[2.5rem]"
|
||||
>
|
||||
{page}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => setCurrentPage(prev => Math.min(Math.ceil(totalUsers / limit), prev + 1))}
|
||||
disabled={currentPage >= Math.ceil(totalUsers / limit)}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dialogs */}
|
||||
<AddUserDialog
|
||||
open={addDialogOpen}
|
||||
onOpenChange={setAddDialogOpen}
|
||||
onUserAdded={fetchUsers}
|
||||
/>
|
||||
|
||||
<EditUserDialog
|
||||
open={editDialogOpen}
|
||||
onOpenChange={setEditDialogOpen}
|
||||
userId={selectedUserId}
|
||||
onUserUpdated={fetchUsers}
|
||||
/>
|
||||
|
||||
<DeleteUserDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
user={userToDelete}
|
||||
onUserDeleted={fetchUsers}
|
||||
/>
|
||||
|
||||
<BulkUploadDialog
|
||||
open={bulkUploadDialogOpen}
|
||||
onOpenChange={setBulkUploadDialogOpen}
|
||||
onUploadComplete={fetchUsers}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
130
apps/control-panel-frontend/src/app/globals.css
Normal file
130
apps/control-panel-frontend/src/app/globals.css
Normal file
@@ -0,0 +1,130 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 221.2 83.2% 53.3%;
|
||||
--primary-foreground: 210 40% 98%;
|
||||
--secondary: 210 40% 96%;
|
||||
--secondary-foreground: 222.2 84% 4.9%;
|
||||
--muted: 210 40% 96%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96%;
|
||||
--accent-foreground: 222.2 84% 4.9%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 221.2 83.2% 53.3%;
|
||||
--radius: 0.5rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 217.2 91.2% 59.8%;
|
||||
--primary-foreground: 222.2 84% 4.9%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 224.3 76.3% 94.1%;
|
||||
}
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom GT 2.0 styles */
|
||||
.gt-gradient {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.gt-card {
|
||||
@apply bg-card text-card-foreground border border-border rounded-lg shadow-sm;
|
||||
}
|
||||
|
||||
.gt-sidebar {
|
||||
@apply bg-muted/50 border-r border-border;
|
||||
}
|
||||
|
||||
.gt-nav-item {
|
||||
@apply flex items-center space-x-3 px-3 py-2 rounded-md text-sm font-medium transition-colors;
|
||||
}
|
||||
|
||||
.gt-nav-item-active {
|
||||
@apply bg-primary text-primary-foreground;
|
||||
}
|
||||
|
||||
.gt-nav-item-inactive {
|
||||
@apply text-muted-foreground hover:bg-accent hover:text-accent-foreground;
|
||||
}
|
||||
|
||||
.gt-status-badge {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.gt-status-active {
|
||||
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300;
|
||||
}
|
||||
|
||||
.gt-status-pending {
|
||||
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300;
|
||||
}
|
||||
|
||||
.gt-status-suspended {
|
||||
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300;
|
||||
}
|
||||
|
||||
.gt-status-deploying {
|
||||
@apply bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300;
|
||||
}
|
||||
|
||||
/* Loading animations */
|
||||
.gt-loading {
|
||||
@apply animate-pulse;
|
||||
}
|
||||
|
||||
.gt-loading-skeleton {
|
||||
@apply bg-muted rounded;
|
||||
}
|
||||
|
||||
/* Table styles */
|
||||
.gt-table {
|
||||
@apply w-full border-collapse;
|
||||
}
|
||||
|
||||
.gt-table th {
|
||||
@apply px-4 py-3 text-left text-xs font-medium text-muted-foreground uppercase tracking-wider border-b border-border;
|
||||
}
|
||||
|
||||
.gt-table td {
|
||||
@apply px-4 py-4 whitespace-nowrap text-sm border-b border-border;
|
||||
}
|
||||
|
||||
.gt-table tr:hover {
|
||||
@apply bg-muted/50;
|
||||
}
|
||||
9
apps/control-panel-frontend/src/app/health/route.ts
Normal file
9
apps/control-panel-frontend/src/app/health/route.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
status: 'healthy',
|
||||
service: 'gt2-control-panel-frontend',
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
56
apps/control-panel-frontend/src/app/layout.tsx
Normal file
56
apps/control-panel-frontend/src/app/layout.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { Metadata } from 'next';
|
||||
import { Inter } from 'next/font/google';
|
||||
import './globals.css';
|
||||
import { Providers } from '@/lib/providers';
|
||||
import { Toaster } from 'react-hot-toast';
|
||||
import Script from 'next/script';
|
||||
|
||||
const inter = Inter({ subsets: ['latin'] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'GT 2.0 Control Panel',
|
||||
description: 'Enterprise AI as a Service Platform - Control Panel',
|
||||
icons: {
|
||||
icon: '/favicon.ico',
|
||||
},
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<Script id="disable-console" strategy="beforeInteractive">
|
||||
{`
|
||||
// Disable console logs in production
|
||||
if (typeof window !== 'undefined' && '${process.env.NEXT_PUBLIC_ENVIRONMENT}' === 'production') {
|
||||
const noop = function() {};
|
||||
['log', 'debug', 'info', 'warn'].forEach(function(method) {
|
||||
console[method] = noop;
|
||||
});
|
||||
}
|
||||
`}
|
||||
</Script>
|
||||
</head>
|
||||
<body className={inter.className}>
|
||||
<Providers>
|
||||
{children}
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
duration: 4000,
|
||||
style: {
|
||||
background: 'hsl(var(--card))',
|
||||
color: 'hsl(var(--card-foreground))',
|
||||
border: '1px solid hsl(var(--border))',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Providers>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
39
apps/control-panel-frontend/src/app/page.tsx
Normal file
39
apps/control-panel-frontend/src/app/page.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export default function HomePage() {
|
||||
const router = useRouter();
|
||||
const { user, isLoading, checkAuth } = useAuthStore();
|
||||
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
}, [checkAuth]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isLoading) {
|
||||
if (user) {
|
||||
router.replace('/dashboard/tenants');
|
||||
} else {
|
||||
router.replace('/auth/login');
|
||||
}
|
||||
}
|
||||
}, [user, isLoading, router]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen bg-background">
|
||||
<div className="flex flex-col items-center space-y-4">
|
||||
<div className="w-16 h-16 bg-primary rounded-lg flex items-center justify-center">
|
||||
<span className="text-2xl font-bold text-primary-foreground">GT</span>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
<span className="text-muted-foreground">Loading GT 2.0...</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
498
apps/control-panel-frontend/src/app/resources/page.tsx
Normal file
498
apps/control-panel-frontend/src/app/resources/page.tsx
Normal file
@@ -0,0 +1,498 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
Brain,
|
||||
Database,
|
||||
GitBranch,
|
||||
Webhook,
|
||||
ExternalLink,
|
||||
GraduationCap,
|
||||
RefreshCw,
|
||||
Settings,
|
||||
CheckCircle,
|
||||
AlertTriangle,
|
||||
XCircle,
|
||||
Activity,
|
||||
Zap,
|
||||
Shield,
|
||||
Users,
|
||||
Building2,
|
||||
MoreVertical,
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '@/stores/auth-store';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
interface Resource {
|
||||
id: number;
|
||||
uuid: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
resource_type: string;
|
||||
resource_subtype?: string;
|
||||
provider: string;
|
||||
model_name?: string;
|
||||
personalization_mode: string;
|
||||
health_status: string;
|
||||
is_active: boolean;
|
||||
priority: number;
|
||||
max_requests_per_minute: number;
|
||||
max_tokens_per_request: number;
|
||||
cost_per_1k_tokens: number;
|
||||
last_health_check?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export default function ResourcesPage() {
|
||||
const { user } = useAuthStore();
|
||||
const router = useRouter();
|
||||
const [resources, setResources] = useState<Resource[]>([]);
|
||||
const [filteredResources, setFilteredResources] = useState<Resource[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedTab, setSelectedTab] = useState('all');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedResources, setSelectedResources] = useState<Set<number>>(new Set());
|
||||
|
||||
// Mock data for development
|
||||
useEffect(() => {
|
||||
const mockResources: Resource[] = [
|
||||
{
|
||||
id: 1,
|
||||
uuid: '123e4567-e89b-12d3-a456-426614174000',
|
||||
name: 'GPT-4 Turbo',
|
||||
description: 'Advanced language model for complex tasks',
|
||||
resource_type: 'ai_ml',
|
||||
resource_subtype: 'llm',
|
||||
provider: 'openai',
|
||||
model_name: 'gpt-4-turbo-preview',
|
||||
personalization_mode: 'shared',
|
||||
health_status: 'healthy',
|
||||
is_active: true,
|
||||
priority: 100,
|
||||
max_requests_per_minute: 500,
|
||||
max_tokens_per_request: 8000,
|
||||
cost_per_1k_tokens: 0.03,
|
||||
last_health_check: new Date().toISOString(),
|
||||
created_at: '2024-01-01T00:00:00Z',
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
uuid: '223e4567-e89b-12d3-a456-426614174001',
|
||||
name: 'Llama 3.1 70B (Groq)',
|
||||
description: 'Fast inference via Groq Cloud',
|
||||
resource_type: 'ai_ml',
|
||||
resource_subtype: 'llm',
|
||||
provider: 'groq',
|
||||
model_name: 'llama-3.1-70b-versatile',
|
||||
personalization_mode: 'shared',
|
||||
health_status: 'healthy',
|
||||
is_active: true,
|
||||
priority: 90,
|
||||
max_requests_per_minute: 1000,
|
||||
max_tokens_per_request: 4096,
|
||||
cost_per_1k_tokens: 0.008,
|
||||
last_health_check: new Date().toISOString(),
|
||||
created_at: '2024-01-02T00:00:00Z',
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
uuid: '323e4567-e89b-12d3-a456-426614174002',
|
||||
name: 'BGE-M3 Embeddings',
|
||||
description: '1024-dimension embedding model on GPU cluster',
|
||||
resource_type: 'ai_ml',
|
||||
resource_subtype: 'embedding',
|
||||
provider: 'local',
|
||||
model_name: 'BAAI/bge-m3',
|
||||
personalization_mode: 'shared',
|
||||
health_status: 'healthy',
|
||||
is_active: true,
|
||||
priority: 95,
|
||||
max_requests_per_minute: 2000,
|
||||
max_tokens_per_request: 512,
|
||||
cost_per_1k_tokens: 0.0001,
|
||||
last_health_check: new Date().toISOString(),
|
||||
created_at: '2024-01-03T00:00:00Z',
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
uuid: '423e4567-e89b-12d3-a456-426614174003',
|
||||
name: 'ChromaDB Vector Store',
|
||||
description: 'Encrypted vector database with user isolation',
|
||||
resource_type: 'rag_engine',
|
||||
resource_subtype: 'vector_database',
|
||||
provider: 'local',
|
||||
model_name: 'chromadb',
|
||||
personalization_mode: 'user_scoped',
|
||||
health_status: 'healthy',
|
||||
is_active: true,
|
||||
priority: 100,
|
||||
max_requests_per_minute: 5000,
|
||||
max_tokens_per_request: 0,
|
||||
cost_per_1k_tokens: 0,
|
||||
last_health_check: new Date().toISOString(),
|
||||
created_at: '2024-01-04T00:00:00Z',
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
uuid: '523e4567-e89b-12d3-a456-426614174004',
|
||||
name: 'Document Processor',
|
||||
description: 'Unstructured.io chunking engine',
|
||||
resource_type: 'rag_engine',
|
||||
resource_subtype: 'document_processor',
|
||||
provider: 'local',
|
||||
personalization_mode: 'shared',
|
||||
health_status: 'healthy',
|
||||
is_active: true,
|
||||
priority: 100,
|
||||
max_requests_per_minute: 100,
|
||||
max_tokens_per_request: 0,
|
||||
cost_per_1k_tokens: 0,
|
||||
last_health_check: new Date().toISOString(),
|
||||
created_at: '2024-01-05T00:00:00Z',
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
uuid: '623e4567-e89b-12d3-a456-426614174005',
|
||||
name: 'Research Agent',
|
||||
description: 'Multi-step research workflow orchestrator',
|
||||
resource_type: 'agentic_workflow',
|
||||
resource_subtype: 'single_agent',
|
||||
provider: 'local',
|
||||
personalization_mode: 'user_scoped',
|
||||
health_status: 'healthy',
|
||||
is_active: true,
|
||||
priority: 90,
|
||||
max_requests_per_minute: 50,
|
||||
max_tokens_per_request: 0,
|
||||
cost_per_1k_tokens: 0,
|
||||
last_health_check: new Date().toISOString(),
|
||||
created_at: '2024-01-06T00:00:00Z',
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
uuid: '723e4567-e89b-12d3-a456-426614174006',
|
||||
name: 'GitHub Connector',
|
||||
description: 'GitHub API integration for DevOps workflows',
|
||||
resource_type: 'app_integration',
|
||||
resource_subtype: 'development',
|
||||
provider: 'custom',
|
||||
personalization_mode: 'user_scoped',
|
||||
health_status: 'unhealthy',
|
||||
is_active: true,
|
||||
priority: 80,
|
||||
max_requests_per_minute: 60,
|
||||
max_tokens_per_request: 0,
|
||||
cost_per_1k_tokens: 0,
|
||||
last_health_check: new Date(Date.now() - 3600000).toISOString(),
|
||||
created_at: '2024-01-07T00:00:00Z',
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
uuid: '823e4567-e89b-12d3-a456-426614174007',
|
||||
name: 'Canvas LMS',
|
||||
description: 'Educational platform integration',
|
||||
resource_type: 'external_service',
|
||||
resource_subtype: 'educational',
|
||||
provider: 'canvas',
|
||||
personalization_mode: 'user_scoped',
|
||||
health_status: 'healthy',
|
||||
is_active: true,
|
||||
priority: 85,
|
||||
max_requests_per_minute: 100,
|
||||
max_tokens_per_request: 0,
|
||||
cost_per_1k_tokens: 0,
|
||||
last_health_check: new Date().toISOString(),
|
||||
created_at: '2024-01-08T00:00:00Z',
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
uuid: '923e4567-e89b-12d3-a456-426614174008',
|
||||
name: 'Strategic Chess Engine',
|
||||
description: 'AI-powered chess with learning analytics',
|
||||
resource_type: 'ai_literacy',
|
||||
resource_subtype: 'strategic_game',
|
||||
provider: 'local',
|
||||
personalization_mode: 'user_scoped',
|
||||
health_status: 'healthy',
|
||||
is_active: true,
|
||||
priority: 70,
|
||||
max_requests_per_minute: 200,
|
||||
max_tokens_per_request: 0,
|
||||
cost_per_1k_tokens: 0,
|
||||
last_health_check: new Date().toISOString(),
|
||||
created_at: '2024-01-09T00:00:00Z',
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
setResources(mockResources);
|
||||
setFilteredResources(mockResources);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
// Filter resources based on tab and search
|
||||
useEffect(() => {
|
||||
let filtered = resources;
|
||||
|
||||
// Filter by resource type
|
||||
if (selectedTab !== 'all') {
|
||||
filtered = filtered.filter(r => r.resource_type === selectedTab);
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery) {
|
||||
filtered = filtered.filter(r =>
|
||||
r.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
r.description?.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
r.provider.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredResources(filtered);
|
||||
}, [selectedTab, searchQuery, resources]);
|
||||
|
||||
const getResourceIcon = (type: string) => {
|
||||
switch (type) {
|
||||
case 'ai_ml': return <Brain className="h-5 w-5" />;
|
||||
case 'rag_engine': return <Database className="h-5 w-5" />;
|
||||
case 'agentic_workflow': return <GitBranch className="h-5 w-5" />;
|
||||
case 'app_integration': return <Webhook className="h-5 w-5" />;
|
||||
case 'external_service': return <ExternalLink className="h-5 w-5" />;
|
||||
case 'ai_literacy': return <GraduationCap className="h-5 w-5" />;
|
||||
default: return <Zap className="h-5 w-5" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getHealthBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'healthy':
|
||||
return <Badge variant="default" className="bg-green-600"><CheckCircle className="h-3 w-3 mr-1" />Healthy</Badge>;
|
||||
case 'unhealthy':
|
||||
return <Badge variant="destructive"><XCircle className="h-3 w-3 mr-1" />Unhealthy</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary"><AlertTriangle className="h-3 w-3 mr-1" />Unknown</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const getPersonalizationBadge = (mode: string) => {
|
||||
switch (mode) {
|
||||
case 'shared':
|
||||
return <Badge variant="secondary"><Users className="h-3 w-3 mr-1" />Shared</Badge>;
|
||||
case 'user_scoped':
|
||||
return <Badge variant="secondary"><Shield className="h-3 w-3 mr-1" />User-Scoped</Badge>;
|
||||
case 'session_based':
|
||||
return <Badge variant="secondary"><Activity className="h-3 w-3 mr-1" />Session-Based</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary">{mode}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const resourceTabs = [
|
||||
{ id: 'all', label: 'All Resources', count: resources.length },
|
||||
{ id: 'ai_ml', label: 'AI/ML Models', icon: <Brain className="h-4 w-4" />, count: resources.filter(r => r.resource_type === 'ai_ml').length },
|
||||
{ id: 'rag_engine', label: 'RAG Engines', icon: <Database className="h-4 w-4" />, count: resources.filter(r => r.resource_type === 'rag_engine').length },
|
||||
{ id: 'agentic_workflow', label: 'Agents', icon: <GitBranch className="h-4 w-4" />, count: resources.filter(r => r.resource_type === 'agentic_workflow').length },
|
||||
{ id: 'app_integration', label: 'Integrations', icon: <Webhook className="h-4 w-4" />, count: resources.filter(r => r.resource_type === 'app_integration').length },
|
||||
{ id: 'external_service', label: 'External', icon: <ExternalLink className="h-4 w-4" />, count: resources.filter(r => r.resource_type === 'external_service').length },
|
||||
{ id: 'ai_literacy', label: 'AI Literacy', icon: <GraduationCap className="h-4 w-4" />, count: resources.filter(r => r.resource_type === 'ai_literacy').length },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Resource Management</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage all GT 2.0 resources across six comprehensive families
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="secondary">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Health Check All
|
||||
</Button>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Resource
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Resource Type Tabs */}
|
||||
<div className="flex space-x-2 border-b">
|
||||
{resourceTabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setSelectedTab(tab.id)}
|
||||
className={`flex items-center space-x-2 px-4 py-2 border-b-2 transition-colors ${
|
||||
selectedTab === tab.id
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
{tab.icon}
|
||||
<span>{tab.label}</span>
|
||||
<Badge variant="secondary" className="ml-2">{tab.count}</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="flex space-x-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search resources by name, provider, or description..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery((e as React.ChangeEvent<HTMLInputElement>).target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="secondary">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
Filters
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Bulk Actions */}
|
||||
{selectedResources.size > 0 && (
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="flex items-center justify-between py-3">
|
||||
<span className="text-sm">
|
||||
{selectedResources.size} resource{selectedResources.size > 1 ? 's' : ''} selected
|
||||
</span>
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="secondary" size="sm">
|
||||
<Building2 className="h-4 w-4 mr-2" />
|
||||
Assign to Tenants
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Health Check
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" className="text-destructive">
|
||||
Disable
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Resources Grid */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<RefreshCw className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
{filteredResources.map(resource => (
|
||||
<Card key={resource.id} className="hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
{getResourceIcon(resource.resource_type)}
|
||||
<div>
|
||||
<CardTitle className="text-lg">{resource.name}</CardTitle>
|
||||
<p className="text-xs text-muted-foreground">{resource.provider}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedResources.has(resource.id)}
|
||||
onChange={(e) => {
|
||||
const newSelected = new Set(selectedResources);
|
||||
if (e.target.checked) {
|
||||
newSelected.add(resource.id);
|
||||
} else {
|
||||
newSelected.delete(resource.id);
|
||||
}
|
||||
setSelectedResources(newSelected);
|
||||
}}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{resource.description && (
|
||||
<p className="text-sm text-muted-foreground">{resource.description}</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{getHealthBadge(resource.health_status)}
|
||||
{getPersonalizationBadge(resource.personalization_mode)}
|
||||
{resource.model_name && (
|
||||
<Badge variant="secondary" className="text-xs">{resource.model_name}</Badge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div>
|
||||
<span className="text-muted-foreground">Rate Limit:</span>
|
||||
<p className="font-medium">{resource.max_requests_per_minute}/min</p>
|
||||
</div>
|
||||
{resource.max_tokens_per_request > 0 && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Max Tokens:</span>
|
||||
<p className="font-medium">{resource.max_tokens_per_request.toLocaleString()}</p>
|
||||
</div>
|
||||
)}
|
||||
{resource.cost_per_1k_tokens > 0 && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">Cost/1K:</span>
|
||||
<p className="font-medium">${resource.cost_per_1k_tokens}</p>
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<span className="text-muted-foreground">Priority:</span>
|
||||
<p className="font-medium">{resource.priority}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{resource.last_health_check && (
|
||||
<div className="pt-2 border-t">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Last checked: {new Date(resource.last_health_check).toLocaleTimeString()}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex space-x-2 pt-2">
|
||||
<Button variant="secondary" size="sm" className="flex-1">
|
||||
<Settings className="h-3 w-3 mr-1" />
|
||||
Configure
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" className="flex-1">
|
||||
<Building2 className="h-3 w-3 mr-1" />
|
||||
Assign
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
498
apps/control-panel-frontend/src/app/tenants/page.tsx
Normal file
498
apps/control-panel-frontend/src/app/tenants/page.tsx
Normal file
@@ -0,0 +1,498 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Building2,
|
||||
Users,
|
||||
Cpu,
|
||||
Activity,
|
||||
CheckCircle,
|
||||
Clock,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Play,
|
||||
Pause,
|
||||
Archive,
|
||||
Settings,
|
||||
Eye,
|
||||
Rocket,
|
||||
Timer,
|
||||
Shield,
|
||||
Database,
|
||||
Cloud,
|
||||
MoreVertical,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface Tenant {
|
||||
id: number;
|
||||
name: string;
|
||||
domain: string;
|
||||
template: string;
|
||||
status: 'active' | 'pending' | 'suspended' | 'archived';
|
||||
max_users: number;
|
||||
current_users: number;
|
||||
namespace: string;
|
||||
resource_count: number;
|
||||
created_at: string;
|
||||
last_activity?: string;
|
||||
deployment_status?: 'deployed' | 'deploying' | 'failed' | 'not_deployed';
|
||||
storage_used_gb?: number;
|
||||
api_calls_today?: number;
|
||||
}
|
||||
|
||||
export default function TenantsPage() {
|
||||
const [tenants, setTenants] = useState<Tenant[]>([]);
|
||||
const [filteredTenants, setFilteredTenants] = useState<Tenant[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
const [selectedTenants, setSelectedTenants] = useState<Set<number>>(new Set());
|
||||
|
||||
// Mock data for development
|
||||
useEffect(() => {
|
||||
const mockTenants: Tenant[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Acme Corporation',
|
||||
domain: 'acme',
|
||||
template: 'enterprise',
|
||||
status: 'active',
|
||||
max_users: 500,
|
||||
current_users: 247,
|
||||
namespace: 'gt-tenant-acme',
|
||||
resource_count: 12,
|
||||
created_at: '2024-01-15T10:00:00Z',
|
||||
last_activity: new Date().toISOString(),
|
||||
deployment_status: 'deployed',
|
||||
storage_used_gb: 45.2,
|
||||
api_calls_today: 15234,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'TechStart Inc',
|
||||
domain: 'techstart',
|
||||
template: 'startup',
|
||||
status: 'pending',
|
||||
max_users: 100,
|
||||
current_users: 0,
|
||||
namespace: 'gt-tenant-techstart',
|
||||
resource_count: 8,
|
||||
created_at: '2024-01-14T14:30:00Z',
|
||||
deployment_status: 'deploying',
|
||||
storage_used_gb: 0,
|
||||
api_calls_today: 0,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Global Solutions',
|
||||
domain: 'global',
|
||||
template: 'enterprise',
|
||||
status: 'active',
|
||||
max_users: 1000,
|
||||
current_users: 623,
|
||||
namespace: 'gt-tenant-global',
|
||||
resource_count: 24,
|
||||
created_at: '2024-01-13T09:15:00Z',
|
||||
last_activity: new Date(Date.now() - 3600000).toISOString(),
|
||||
deployment_status: 'deployed',
|
||||
storage_used_gb: 128.7,
|
||||
api_calls_today: 42156,
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Education First',
|
||||
domain: 'edufirst',
|
||||
template: 'education',
|
||||
status: 'active',
|
||||
max_users: 2000,
|
||||
current_users: 1456,
|
||||
namespace: 'gt-tenant-edufirst',
|
||||
resource_count: 18,
|
||||
created_at: '2024-01-10T11:00:00Z',
|
||||
last_activity: new Date(Date.now() - 600000).toISOString(),
|
||||
deployment_status: 'deployed',
|
||||
storage_used_gb: 89.3,
|
||||
api_calls_today: 28934,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'CyberDefense Corp',
|
||||
domain: 'cyberdef',
|
||||
template: 'cybersecurity',
|
||||
status: 'active',
|
||||
max_users: 300,
|
||||
current_users: 189,
|
||||
namespace: 'gt-tenant-cyberdef',
|
||||
resource_count: 21,
|
||||
created_at: '2024-01-08T08:45:00Z',
|
||||
last_activity: new Date(Date.now() - 1800000).toISOString(),
|
||||
deployment_status: 'deployed',
|
||||
storage_used_gb: 67.4,
|
||||
api_calls_today: 19876,
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Beta Testers LLC',
|
||||
domain: 'betatest',
|
||||
template: 'development',
|
||||
status: 'suspended',
|
||||
max_users: 50,
|
||||
current_users: 12,
|
||||
namespace: 'gt-tenant-betatest',
|
||||
resource_count: 5,
|
||||
created_at: '2024-01-05T15:20:00Z',
|
||||
last_activity: new Date(Date.now() - 86400000).toISOString(),
|
||||
deployment_status: 'deployed',
|
||||
storage_used_gb: 12.1,
|
||||
api_calls_today: 0,
|
||||
},
|
||||
];
|
||||
|
||||
setTenants(mockTenants);
|
||||
setFilteredTenants(mockTenants);
|
||||
setLoading(false);
|
||||
}, []);
|
||||
|
||||
// Filter tenants based on search and status
|
||||
useEffect(() => {
|
||||
let filtered = tenants;
|
||||
|
||||
// Filter by status
|
||||
if (statusFilter !== 'all') {
|
||||
filtered = filtered.filter(t => t.status === statusFilter);
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery) {
|
||||
filtered = filtered.filter(t =>
|
||||
t.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
t.domain.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
t.template.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredTenants(filtered);
|
||||
}, [statusFilter, searchQuery, tenants]);
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return <Badge variant="default" className="bg-green-600"><CheckCircle className="h-3 w-3 mr-1" />Active</Badge>;
|
||||
case 'pending':
|
||||
return <Badge variant="secondary"><Clock className="h-3 w-3 mr-1" />Pending</Badge>;
|
||||
case 'suspended':
|
||||
return <Badge variant="destructive"><Pause className="h-3 w-3 mr-1" />Suspended</Badge>;
|
||||
case 'archived':
|
||||
return <Badge variant="secondary"><Archive className="h-3 w-3 mr-1" />Archived</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const getDeploymentBadge = (status?: string) => {
|
||||
switch (status) {
|
||||
case 'deployed':
|
||||
return <Badge variant="secondary" className="text-green-600"><Cloud className="h-3 w-3 mr-1" />Deployed</Badge>;
|
||||
case 'deploying':
|
||||
return <Badge variant="secondary" className="text-blue-600"><Rocket className="h-3 w-3 mr-1" />Deploying</Badge>;
|
||||
case 'failed':
|
||||
return <Badge variant="secondary" className="text-red-600"><XCircle className="h-3 w-3 mr-1" />Failed</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary"><AlertTriangle className="h-3 w-3 mr-1" />Not Deployed</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const getTemplateBadge = (template: string) => {
|
||||
const colors: Record<string, string> = {
|
||||
enterprise: 'bg-purple-600',
|
||||
startup: 'bg-blue-600',
|
||||
education: 'bg-green-600',
|
||||
cybersecurity: 'bg-red-600',
|
||||
development: 'bg-yellow-600',
|
||||
};
|
||||
return (
|
||||
<Badge className={colors[template] || 'bg-gray-600'}>
|
||||
{template.charAt(0).toUpperCase() + template.slice(1)}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const statusTabs = [
|
||||
{ id: 'all', label: 'All Tenants', count: tenants.length },
|
||||
{ id: 'active', label: 'Active', count: tenants.filter(t => t.status === 'active').length },
|
||||
{ id: 'pending', label: 'Pending', count: tenants.filter(t => t.status === 'pending').length },
|
||||
{ id: 'suspended', label: 'Suspended', count: tenants.filter(t => t.status === 'suspended').length },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Tenant Management</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage tenant deployments with 5-minute onboarding
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="secondary">
|
||||
<Timer className="h-4 w-4 mr-2" />
|
||||
Bulk Deploy
|
||||
</Button>
|
||||
<Button className="bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700">
|
||||
<Rocket className="h-4 w-4 mr-2" />
|
||||
5-Min Onboarding
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Tenants</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{tenants.length}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{tenants.filter(t => t.status === 'active').length} active
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{tenants.reduce((sum, t) => sum + t.current_users, 0).toLocaleString()}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Across all tenants
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Storage Used</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{(tenants.reduce((sum, t) => sum + (t.storage_used_gb || 0), 0) / 1024).toFixed(1)} TB
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Total consumption
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">API Calls Today</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{tenants.reduce((sum, t) => sum + (t.api_calls_today || 0), 0).toLocaleString()}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
All tenants combined
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Status Tabs */}
|
||||
<div className="flex space-x-2 border-b">
|
||||
{statusTabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setStatusFilter(tab.id)}
|
||||
className={`px-4 py-2 border-b-2 transition-colors ${
|
||||
statusFilter === tab.id
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
<Badge variant="secondary" className="ml-2">{tab.count}</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex space-x-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search tenants by name, domain, or template..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery((e as React.ChangeEvent<HTMLInputElement>).target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk Actions */}
|
||||
{selectedTenants.size > 0 && (
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="flex items-center justify-between py-3">
|
||||
<span className="text-sm">
|
||||
{selectedTenants.size} tenant{selectedTenants.size > 1 ? 's' : ''} selected
|
||||
</span>
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="secondary" size="sm">
|
||||
<Cpu className="h-4 w-4 mr-2" />
|
||||
Assign Resources
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm">
|
||||
<Play className="h-4 w-4 mr-2" />
|
||||
Deploy All
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" className="text-destructive">
|
||||
<Pause className="h-4 w-4 mr-2" />
|
||||
Suspend
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Tenants Table */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Activity className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="border-b bg-muted/50">
|
||||
<tr>
|
||||
<th className="p-4 text-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedTenants(new Set(filteredTenants.map(t => t.id)));
|
||||
} else {
|
||||
setSelectedTenants(new Set());
|
||||
}
|
||||
}}
|
||||
checked={selectedTenants.size === filteredTenants.length && filteredTenants.length > 0}
|
||||
/>
|
||||
</th>
|
||||
<th className="p-4 text-left font-medium">Tenant</th>
|
||||
<th className="p-4 text-left font-medium">Status</th>
|
||||
<th className="p-4 text-left font-medium">Template</th>
|
||||
<th className="p-4 text-left font-medium">Users</th>
|
||||
<th className="p-4 text-left font-medium">Resources</th>
|
||||
<th className="p-4 text-left font-medium">Usage</th>
|
||||
<th className="p-4 text-left font-medium">Activity</th>
|
||||
<th className="p-4 text-left font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredTenants.map(tenant => (
|
||||
<tr key={tenant.id} className="border-b hover:bg-muted/30">
|
||||
<td className="p-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedTenants.has(tenant.id)}
|
||||
onChange={(e) => {
|
||||
const newSelected = new Set(selectedTenants);
|
||||
if (e.target.checked) {
|
||||
newSelected.add(tenant.id);
|
||||
} else {
|
||||
newSelected.delete(tenant.id);
|
||||
}
|
||||
setSelectedTenants(newSelected);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div>
|
||||
<div className="font-medium">{tenant.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{tenant.domain}.gt2.com</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">{tenant.namespace}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div className="space-y-1">
|
||||
{getStatusBadge(tenant.status)}
|
||||
{getDeploymentBadge(tenant.deployment_status)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{getTemplateBadge(tenant.template)}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div>
|
||||
<div className="font-medium">{tenant.current_users}</div>
|
||||
<div className="text-xs text-muted-foreground">of {tenant.max_users}</div>
|
||||
<div className="w-full bg-secondary rounded-full h-1.5 mt-1">
|
||||
<div
|
||||
className="bg-primary h-1.5 rounded-full"
|
||||
style={{ width: `${(tenant.current_users / tenant.max_users) * 100}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div className="flex items-center space-x-1">
|
||||
<Cpu className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">{tenant.resource_count}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div className="space-y-1 text-sm">
|
||||
{tenant.storage_used_gb && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Database className="h-3 w-3 text-muted-foreground" />
|
||||
<span>{tenant.storage_used_gb.toFixed(1)} GB</span>
|
||||
</div>
|
||||
)}
|
||||
{tenant.api_calls_today && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Activity className="h-3 w-3 text-muted-foreground" />
|
||||
<span>{tenant.api_calls_today.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{tenant.last_activity && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{new Date(tenant.last_activity).toLocaleTimeString()}
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div className="flex space-x-1">
|
||||
<Button variant="ghost" size="sm">
|
||||
<Eye className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm">
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
463
apps/control-panel-frontend/src/app/users/page.tsx
Normal file
463
apps/control-panel-frontend/src/app/users/page.tsx
Normal file
@@ -0,0 +1,463 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { usersApi } from '@/lib/api';
|
||||
import toast from 'react-hot-toast';
|
||||
import AddUserDialog from '@/components/users/AddUserDialog';
|
||||
import EditUserDialog from '@/components/users/EditUserDialog';
|
||||
import DeleteUserDialog from '@/components/users/DeleteUserDialog';
|
||||
import BulkUploadDialog from '@/components/users/BulkUploadDialog';
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
Users,
|
||||
User,
|
||||
Shield,
|
||||
Key,
|
||||
Building2,
|
||||
Activity,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
Settings,
|
||||
Eye,
|
||||
Mail,
|
||||
Calendar,
|
||||
Clock,
|
||||
MoreVertical,
|
||||
UserCog,
|
||||
ShieldCheck,
|
||||
Lock,
|
||||
Edit,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
|
||||
interface UserType {
|
||||
id: number;
|
||||
email: string;
|
||||
full_name: string;
|
||||
user_type: 'gt_admin' | 'tenant_admin' | 'tenant_user';
|
||||
tenant_id?: number;
|
||||
tenant_name?: string;
|
||||
status: 'active' | 'inactive' | 'suspended';
|
||||
capabilities: string[];
|
||||
access_groups: string[];
|
||||
last_login?: string;
|
||||
created_at: string;
|
||||
api_calls_today?: number;
|
||||
active_sessions?: number;
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
const [users, setUsers] = useState<UserType[]>([]);
|
||||
const [filteredUsers, setFilteredUsers] = useState<UserType[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState('all');
|
||||
const [selectedUsers, setSelectedUsers] = useState<Set<number>>(new Set());
|
||||
|
||||
// Dialog states
|
||||
const [addDialogOpen, setAddDialogOpen] = useState(false);
|
||||
const [editDialogOpen, setEditDialogOpen] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [bulkUploadDialogOpen, setBulkUploadDialogOpen] = useState(false);
|
||||
const [selectedUserId, setSelectedUserId] = useState<number | null>(null);
|
||||
const [userToDelete, setUserToDelete] = useState<{
|
||||
id: number;
|
||||
email: string;
|
||||
full_name: string;
|
||||
user_type: string;
|
||||
} | null>(null);
|
||||
|
||||
// Fetch real users from API - GT 2.0 "No Mocks" principle
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
}, []);
|
||||
|
||||
const fetchUsers = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await usersApi.list(1, 100);
|
||||
const userData = response.data?.users || response.data?.data || [];
|
||||
|
||||
// Map API response to expected format
|
||||
const mappedUsers: UserType[] = userData.map((user: any) => ({
|
||||
...user,
|
||||
status: user.is_active ? 'active' : 'suspended',
|
||||
api_calls_today: 0, // Will be populated by analytics API
|
||||
active_sessions: 0, // Will be populated by sessions API
|
||||
capabilities: user.capabilities || [],
|
||||
access_groups: user.access_groups || [],
|
||||
}));
|
||||
|
||||
setUsers(mappedUsers);
|
||||
setFilteredUsers(mappedUsers);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch users:', error);
|
||||
toast.error('Failed to load users');
|
||||
setUsers([]);
|
||||
setFilteredUsers([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Filter users based on type and search
|
||||
useEffect(() => {
|
||||
let filtered = users;
|
||||
|
||||
// Filter by user type
|
||||
if (typeFilter !== 'all') {
|
||||
filtered = filtered.filter(u => u.user_type === typeFilter);
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery) {
|
||||
filtered = filtered.filter(u =>
|
||||
u.full_name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
u.email.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
u.tenant_name?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredUsers(filtered);
|
||||
}, [typeFilter, searchQuery, users]);
|
||||
|
||||
const getStatusBadge = (status: string) => {
|
||||
switch (status) {
|
||||
case 'active':
|
||||
return <Badge variant="default" className="bg-green-600"><CheckCircle className="h-3 w-3 mr-1" />Active</Badge>;
|
||||
case 'inactive':
|
||||
return <Badge variant="secondary"><Clock className="h-3 w-3 mr-1" />Inactive</Badge>;
|
||||
case 'suspended':
|
||||
return <Badge variant="destructive"><XCircle className="h-3 w-3 mr-1" />Suspended</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary">{status}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const getUserTypeBadge = (type: string) => {
|
||||
switch (type) {
|
||||
case 'gt_admin':
|
||||
return <Badge className="bg-purple-600"><ShieldCheck className="h-3 w-3 mr-1" />GT Admin</Badge>;
|
||||
case 'tenant_admin':
|
||||
return <Badge className="bg-blue-600"><UserCog className="h-3 w-3 mr-1" />Tenant Admin</Badge>;
|
||||
case 'tenant_user':
|
||||
return <Badge variant="secondary"><User className="h-3 w-3 mr-1" />User</Badge>;
|
||||
default:
|
||||
return <Badge variant="secondary">{type}</Badge>;
|
||||
}
|
||||
};
|
||||
|
||||
const typeTabs = [
|
||||
{ id: 'all', label: 'All Users', count: users.length },
|
||||
{ id: 'gt_admin', label: 'GT Admins', count: users.filter(u => u.user_type === 'gt_admin').length },
|
||||
{ id: 'tenant_admin', label: 'Tenant Admins', count: users.filter(u => u.user_type === 'tenant_admin').length },
|
||||
{ id: 'tenant_user', label: 'Tenant Users', count: users.filter(u => u.user_type === 'tenant_user').length },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">User Management</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage users, capabilities, and access groups across all tenants
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="secondary">
|
||||
<Shield className="h-4 w-4 mr-2" />
|
||||
Access Groups
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => setBulkUploadDialogOpen(true)}>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Bulk Upload
|
||||
</Button>
|
||||
<Button onClick={() => setAddDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add User
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{users.length}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{users.filter(u => u.status === 'active').length} active
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Active Sessions</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{users.reduce((sum, u) => sum + (u.active_sessions || 0), 0)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Currently online
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">API Usage</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{(users.reduce((sum, u) => sum + (u.api_calls_today || 0), 0) / 1000).toFixed(1)}K
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Calls today
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm font-medium">Access Groups</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">
|
||||
{Array.from(new Set(users.flatMap(u => u.access_groups))).length}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Unique groups
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Type Tabs */}
|
||||
<div className="flex space-x-2 border-b">
|
||||
{typeTabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
onClick={() => setTypeFilter(tab.id)}
|
||||
className={`px-4 py-2 border-b-2 transition-colors ${
|
||||
typeFilter === tab.id
|
||||
? 'border-primary text-primary'
|
||||
: 'border-transparent text-muted-foreground hover:text-foreground'
|
||||
}`}
|
||||
>
|
||||
<span>{tab.label}</span>
|
||||
<Badge variant="secondary" className="ml-2">{tab.count}</Badge>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Search and Filters */}
|
||||
<div className="flex space-x-4">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search users by name, email, or tenant..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery((e as React.ChangeEvent<HTMLInputElement>).target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Button variant="secondary">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
Filters
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Bulk Actions */}
|
||||
{selectedUsers.size > 0 && (
|
||||
<Card className="bg-muted/50">
|
||||
<CardContent className="flex items-center justify-between py-3">
|
||||
<span className="text-sm">
|
||||
{selectedUsers.size} user{selectedUsers.size > 1 ? 's' : ''} selected
|
||||
</span>
|
||||
<div className="flex space-x-2">
|
||||
<Button variant="secondary" size="sm">
|
||||
<Key className="h-4 w-4 mr-2" />
|
||||
Reset Passwords
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" className="text-destructive">
|
||||
<Lock className="h-4 w-4 mr-2" />
|
||||
Suspend
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Users Table */}
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Activity className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="border-b bg-muted/50">
|
||||
<tr>
|
||||
<th className="p-4 text-left">
|
||||
<input
|
||||
type="checkbox"
|
||||
onChange={(e) => {
|
||||
if (e.target.checked) {
|
||||
setSelectedUsers(new Set(filteredUsers.map(u => u.id)));
|
||||
} else {
|
||||
setSelectedUsers(new Set());
|
||||
}
|
||||
}}
|
||||
checked={selectedUsers.size === filteredUsers.length && filteredUsers.length > 0}
|
||||
/>
|
||||
</th>
|
||||
<th className="p-4 text-left font-medium">User</th>
|
||||
<th className="p-4 text-left font-medium">Type</th>
|
||||
<th className="p-4 text-left font-medium">Tenant</th>
|
||||
<th className="p-4 text-left font-medium">Status</th>
|
||||
<th className="p-4 text-left font-medium">Activity</th>
|
||||
<th className="p-4 text-left font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredUsers.map(user => (
|
||||
<tr key={user.id} className="border-b hover:bg-muted/30">
|
||||
<td className="p-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedUsers.has(user.id)}
|
||||
onChange={(e) => {
|
||||
const newSelected = new Set(selectedUsers);
|
||||
if (e.target.checked) {
|
||||
newSelected.add(user.id);
|
||||
} else {
|
||||
newSelected.delete(user.id);
|
||||
}
|
||||
setSelectedUsers(newSelected);
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div>
|
||||
<div className="font-medium">{user.full_name}</div>
|
||||
<div className="text-sm text-muted-foreground flex items-center space-x-1">
|
||||
<Mail className="h-3 w-3" />
|
||||
<span>{user.email}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{getUserTypeBadge(user.user_type)}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{user.tenant_name ? (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Building2 className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{user.tenant_name}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground">System</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
{getStatusBadge(user.status)}
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div className="space-y-1 text-sm">
|
||||
{user.last_login && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Clock className="h-3 w-3 text-muted-foreground" />
|
||||
<span>{new Date(user.last_login).toLocaleTimeString()}</span>
|
||||
</div>
|
||||
)}
|
||||
{user.api_calls_today !== undefined && (
|
||||
<div className="flex items-center space-x-1">
|
||||
<Activity className="h-3 w-3 text-muted-foreground" />
|
||||
<span>{user.api_calls_today} calls</span>
|
||||
</div>
|
||||
)}
|
||||
{user.active_sessions !== undefined && user.active_sessions > 0 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{user.active_sessions} session{user.active_sessions > 1 ? 's' : ''}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-4">
|
||||
<div className="flex space-x-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setSelectedUserId(user.id);
|
||||
setEditDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setUserToDelete({
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
full_name: user.full_name,
|
||||
user_type: user.user_type,
|
||||
});
|
||||
setDeleteDialogOpen(true);
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Dialogs */}
|
||||
<AddUserDialog
|
||||
open={addDialogOpen}
|
||||
onOpenChange={setAddDialogOpen}
|
||||
onUserAdded={fetchUsers}
|
||||
/>
|
||||
|
||||
<EditUserDialog
|
||||
open={editDialogOpen}
|
||||
onOpenChange={setEditDialogOpen}
|
||||
userId={selectedUserId}
|
||||
onUserUpdated={fetchUsers}
|
||||
/>
|
||||
|
||||
<DeleteUserDialog
|
||||
open={deleteDialogOpen}
|
||||
onOpenChange={setDeleteDialogOpen}
|
||||
user={userToDelete}
|
||||
onUserDeleted={fetchUsers}
|
||||
/>
|
||||
|
||||
<BulkUploadDialog
|
||||
open={bulkUploadDialogOpen}
|
||||
onOpenChange={setBulkUploadDialogOpen}
|
||||
onUploadComplete={fetchUsers}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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 '{provider.keyPrefix}'
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
141
apps/control-panel-frontend/src/components/ui/alert-dialog.tsx
Normal file
141
apps/control-panel-frontend/src/components/ui/alert-dialog.tsx
Normal 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,
|
||||
}
|
||||
58
apps/control-panel-frontend/src/components/ui/alert.tsx
Normal file
58
apps/control-panel-frontend/src/components/ui/alert.tsx
Normal 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 }
|
||||
48
apps/control-panel-frontend/src/components/ui/avatar.tsx
Normal file
48
apps/control-panel-frontend/src/components/ui/avatar.tsx
Normal 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 }
|
||||
36
apps/control-panel-frontend/src/components/ui/badge.tsx
Normal file
36
apps/control-panel-frontend/src/components/ui/badge.tsx
Normal 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 }
|
||||
57
apps/control-panel-frontend/src/components/ui/button.tsx
Normal file
57
apps/control-panel-frontend/src/components/ui/button.tsx
Normal 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 }
|
||||
68
apps/control-panel-frontend/src/components/ui/card.tsx
Normal file
68
apps/control-panel-frontend/src/components/ui/card.tsx
Normal 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 }
|
||||
122
apps/control-panel-frontend/src/components/ui/dialog.tsx
Normal file
122
apps/control-panel-frontend/src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
199
apps/control-panel-frontend/src/components/ui/dropdown-menu.tsx
Normal file
199
apps/control-panel-frontend/src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
}
|
||||
105
apps/control-panel-frontend/src/components/ui/error-boundary.tsx
Normal file
105
apps/control-panel-frontend/src/components/ui/error-boundary.tsx
Normal 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 };
|
||||
25
apps/control-panel-frontend/src/components/ui/input.tsx
Normal file
25
apps/control-panel-frontend/src/components/ui/input.tsx
Normal 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 }
|
||||
24
apps/control-panel-frontend/src/components/ui/label.tsx
Normal file
24
apps/control-panel-frontend/src/components/ui/label.tsx
Normal 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 }
|
||||
78
apps/control-panel-frontend/src/components/ui/loading.tsx
Normal file
78
apps/control-panel-frontend/src/components/ui/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
apps/control-panel-frontend/src/components/ui/progress.tsx
Normal file
28
apps/control-panel-frontend/src/components/ui/progress.tsx
Normal 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 }
|
||||
160
apps/control-panel-frontend/src/components/ui/select.tsx
Normal file
160
apps/control-panel-frontend/src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
31
apps/control-panel-frontend/src/components/ui/separator.tsx
Normal file
31
apps/control-panel-frontend/src/components/ui/separator.tsx
Normal 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 }
|
||||
29
apps/control-panel-frontend/src/components/ui/switch.tsx
Normal file
29
apps/control-panel-frontend/src/components/ui/switch.tsx
Normal 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 }
|
||||
116
apps/control-panel-frontend/src/components/ui/table.tsx
Normal file
116
apps/control-panel-frontend/src/components/ui/table.tsx
Normal 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,
|
||||
}
|
||||
55
apps/control-panel-frontend/src/components/ui/tabs.tsx
Normal file
55
apps/control-panel-frontend/src/components/ui/tabs.tsx
Normal 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 }
|
||||
24
apps/control-panel-frontend/src/components/ui/textarea.tsx
Normal file
24
apps/control-panel-frontend/src/components/ui/textarea.tsx
Normal 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 }
|
||||
30
apps/control-panel-frontend/src/components/ui/use-toast.ts
Normal file
30
apps/control-panel-frontend/src/components/ui/use-toast.ts
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
24
apps/control-panel-frontend/src/config/session.ts
Normal file
24
apps/control-panel-frontend/src/config/session.ts
Normal 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;
|
||||
447
apps/control-panel-frontend/src/lib/api.ts
Normal file
447
apps/control-panel-frontend/src/lib/api.ts
Normal 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;
|
||||
37
apps/control-panel-frontend/src/lib/providers.tsx
Normal file
37
apps/control-panel-frontend/src/lib/providers.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
apps/control-panel-frontend/src/lib/utils.ts
Normal file
72
apps/control-panel-frontend/src/lib/utils.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
167
apps/control-panel-frontend/src/providers/session-monitor.tsx
Normal file
167
apps/control-panel-frontend/src/providers/session-monitor.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
258
apps/control-panel-frontend/src/services/tfa.ts
Normal file
258
apps/control-panel-frontend/src/services/tfa.ts
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
221
apps/control-panel-frontend/src/stores/auth-store.ts
Normal file
221
apps/control-panel-frontend/src/stores/auth-store.ts
Normal 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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
100
apps/control-panel-frontend/src/types/tfa.ts
Normal file
100
apps/control-panel-frontend/src/types/tfa.ts
Normal 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;
|
||||
}
|
||||
76
apps/control-panel-frontend/tailwind.config.js
Normal file
76
apps/control-panel-frontend/tailwind.config.js
Normal 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")],
|
||||
}
|
||||
32
apps/control-panel-frontend/tsconfig.json
Normal file
32
apps/control-panel-frontend/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user