diff --git a/api/cmd/main.go b/api/cmd/main.go index 7abd21a8..dd53eafb 100644 --- a/api/cmd/main.go +++ b/api/cmd/main.go @@ -180,12 +180,13 @@ func main() { auditLogger := middleware.NewAuditLogger(database, false) // Don't log request bodies by default router.Use(auditLogger.Middleware()) - // Add gzip compression (exclude WebSocket and auth endpoints) + // Add gzip compression (exclude WebSocket, auth, and metrics endpoints) router.Use(middleware.GzipWithExclusions( middleware.BestSpeed, // Use best speed for balance of compression vs CPU []string{ - "/api/v1/ws/", // Exclude WebSocket paths - "/api/v1/auth/", // Exclude auth endpoints (setup, login, etc.) + "/api/v1/ws/", // Exclude WebSocket paths + "/api/v1/auth/", // Exclude auth endpoints (setup, login, etc.) + "/api/v1/metrics", // Exclude metrics (browser handles decompression inconsistently) }, )) diff --git a/api/internal/api/handlers.go b/api/internal/api/handlers.go index be99e170..9b06a61d 100644 --- a/api/internal/api/handlers.go +++ b/api/internal/api/handlers.go @@ -1393,13 +1393,13 @@ func (h *Handler) InstallCatalogTemplate(c *gin.Context) { // Repository Endpoints (Template Repository Management) // ============================================================================ -// ListRepositories returns all template repositories +// ListRepositories returns all template and plugin repositories func (h *Handler) ListRepositories(c *gin.Context) { // SECURITY FIX: Use request context for proper cancellation and timeout handling ctx := c.Request.Context() rows, err := h.db.DB().QueryContext(ctx, ` - SELECT id, name, url, branch, auth_type, last_sync, template_count, status, error_message, created_at, updated_at + SELECT id, name, url, branch, COALESCE(type, 'template'), auth_type, last_sync, template_count, status, error_message, created_at, updated_at FROM repositories ORDER BY name ASC `) @@ -1412,27 +1412,42 @@ func (h *Handler) ListRepositories(c *gin.Context) { repos := []map[string]interface{}{} for rows.Next() { var id int - var name, url, branch, authType, status, errorMessage string - var lastSync, createdAt, updatedAt time.Time + var name, url, branch, repoType, authType, status string + var lastSync sql.NullTime + var errorMessage sql.NullString + var createdAt, updatedAt time.Time var templateCount int - if err := rows.Scan(&id, &name, &url, &branch, &authType, &lastSync, &templateCount, &status, &errorMessage, &createdAt, &updatedAt); err != nil { + if err := rows.Scan(&id, &name, &url, &branch, &repoType, &authType, &lastSync, &templateCount, &status, &errorMessage, &createdAt, &updatedAt); err != nil { continue } - repos = append(repos, map[string]interface{}{ + repo := map[string]interface{}{ "id": id, "name": name, "url": url, "branch": branch, + "type": repoType, "authType": authType, - "lastSync": lastSync, "templateCount": templateCount, "status": status, - "errorMessage": errorMessage, "createdAt": createdAt, "updatedAt": updatedAt, - }) + } + + if lastSync.Valid { + repo["lastSync"] = lastSync.Time + } else { + repo["lastSync"] = nil + } + + if errorMessage.Valid { + repo["errorMessage"] = errorMessage.String + } else { + repo["errorMessage"] = "" + } + + repos = append(repos, repo) } c.JSON(http.StatusOK, gin.H{ diff --git a/api/internal/db/database.go b/api/internal/db/database.go index 7c3bf16a..7b9e76f3 100644 --- a/api/internal/db/database.go +++ b/api/internal/db/database.go @@ -363,12 +363,13 @@ func (d *Database) Migrate() error { // Create index on session_id `CREATE INDEX IF NOT EXISTS idx_connections_session_id ON connections(session_id)`, - // Template repositories + // Template and plugin repositories `CREATE TABLE IF NOT EXISTS repositories ( id SERIAL PRIMARY KEY, name VARCHAR(255) UNIQUE, url TEXT NOT NULL, branch VARCHAR(100) DEFAULT 'main', + type VARCHAR(50) DEFAULT 'template', auth_type VARCHAR(50) DEFAULT 'none', auth_secret VARCHAR(255), last_sync TIMESTAMP, @@ -380,9 +381,9 @@ func (d *Database) Migrate() error { )`, // Insert default repositories (plugins and templates) - `INSERT INTO repositories (name, url, branch, auth_type, status) VALUES - ('Official Plugins', 'https://github.com/JoshuaAFerguson/streamspace-plugins', 'main', 'none', 'active'), - ('Official Templates', 'https://github.com/JoshuaAFerguson/streamspace-templates', 'main', 'none', 'active') + `INSERT INTO repositories (name, url, branch, type, auth_type, status) VALUES + ('Official Plugins', 'https://github.com/JoshuaAFerguson/streamspace-plugins', 'main', 'plugin', 'none', 'pending'), + ('Official Templates', 'https://github.com/JoshuaAFerguson/streamspace-templates', 'main', 'template', 'none', 'pending') ON CONFLICT (name) DO NOTHING`, // Catalog templates (cache of templates from repos) @@ -523,6 +524,16 @@ func (d *Database) Migrate() error { VALUES ('admin', 100, '64000m', '256Gi', '1Ti') ON CONFLICT (user_id) DO NOTHING`, + // Insert default all_users group that all users belong to + `INSERT INTO groups (id, name, display_name, description, type) + VALUES ('all-users', 'all_users', 'All Users', 'Default group containing all users', 'system') + ON CONFLICT (id) DO NOTHING`, + + // Add admin user to all_users group + `INSERT INTO group_memberships (id, user_id, group_id, role, created_at) + VALUES ('admin-all-users', 'admin', 'all-users', 'member', NOW()) + ON CONFLICT (user_id, group_id) DO NOTHING`, + // Insert default configuration values `INSERT INTO configuration (key, value, category, description) VALUES ('ingress.domain', 'streamspace.local', 'ingress', 'Default ingress domain'), diff --git a/api/internal/db/users.go b/api/internal/db/users.go index a8207fa7..e9187c84 100644 --- a/api/internal/db/users.go +++ b/api/internal/db/users.go @@ -157,6 +157,12 @@ func (u *UserDB) CreateUser(ctx context.Context, req *models.CreateUserRequest) return nil, fmt.Errorf("failed to create default quota: %w", err) } + // Add user to all_users group + if err := u.addToAllUsersGroup(ctx, user.ID); err != nil { + // Log but don't fail user creation + fmt.Printf("Warning: failed to add user %s to all_users group: %v\n", user.ID, err) + } + return user, nil } @@ -576,6 +582,19 @@ func (u *UserDB) createDefaultQuota(ctx context.Context, userID string) error { return err } +// addToAllUsersGroup adds a user to the default all_users group +func (u *UserDB) addToAllUsersGroup(ctx context.Context, userID string) error { + query := ` + INSERT INTO group_memberships (id, user_id, group_id, role, created_at) + SELECT $1, $2, id, 'member', NOW() + FROM groups WHERE name = 'all_users' + ON CONFLICT (user_id, group_id) DO NOTHING + ` + + _, err := u.db.ExecContext(ctx, query, uuid.New().String(), userID) + return err +} + // createQuota creates quota with custom values func (u *UserDB) createQuota(ctx context.Context, userID string, req *models.SetQuotaRequest) error { maxSessions := 5 diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 52936b3d..a9e21bc9 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense } from 'react'; +import { lazy, Suspense, useState, useMemo, createContext, useContext } from 'react'; import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { ThemeProvider, createTheme, CssBaseline, CircularProgress, Box } from '@mui/material'; @@ -6,6 +6,20 @@ import { useUserStore } from './store/userStore'; import ErrorBoundary from './components/ErrorBoundary'; import NotificationQueue from './components/NotificationQueue'; +// Theme mode context +type ThemeMode = 'light' | 'dark'; +interface ThemeContextType { + mode: ThemeMode; + toggleTheme: () => void; +} + +const ThemeContext = createContext({ + mode: 'dark', + toggleTheme: () => {}, +}); + +export const useThemeMode = () => useContext(ThemeContext); + // Eagerly load Login page and SetupWizard (needed immediately) import Login from './pages/Login'; import SetupWizard from './pages/SetupWizard'; @@ -30,7 +44,6 @@ const InstalledPlugins = lazy(() => import('./pages/InstalledPlugins')); // Admin Pages (loaded only for admin users) const AdminDashboard = lazy(() => import('./pages/admin/Dashboard')); const AdminNodes = lazy(() => import('./pages/admin/Nodes')); -const AdminQuotas = lazy(() => import('./pages/admin/Quotas')); const AdminPlugins = lazy(() => import('./pages/admin/Plugins')); const Users = lazy(() => import('./pages/admin/Users')); const UserDetail = lazy(() => import('./pages/admin/UserDetail')); @@ -53,34 +66,41 @@ const queryClient = new QueryClient({ }, }); -// Create Material-UI theme -const theme = createTheme({ - palette: { - mode: 'dark', - primary: { - main: '#3f51b5', - }, - secondary: { - main: '#f50057', +// Create Material-UI theme based on mode +const createAppTheme = (mode: ThemeMode) => + createTheme({ + palette: { + mode, + primary: { + main: '#3f51b5', + }, + secondary: { + main: '#f50057', + }, + background: + mode === 'dark' + ? { + default: '#0a1929', + paper: '#132f4c', + } + : { + default: '#f5f5f5', + paper: '#ffffff', + }, }, - background: { - default: '#0a1929', - paper: '#132f4c', + typography: { + fontFamily: '"Inter", "Roboto", "Helvetica", "Arial", sans-serif', }, - }, - typography: { - fontFamily: '"Inter", "Roboto", "Helvetica", "Arial", sans-serif', - }, - components: { - MuiCard: { - styleOverrides: { - root: { - backgroundImage: 'none', + components: { + MuiCard: { + styleOverrides: { + root: { + backgroundImage: 'none', + }, }, }, }, - }, -}); + }); // Protected Route wrapper function ProtectedRoute({ children }: { children: React.ReactNode }) { @@ -127,14 +147,36 @@ function PageLoader() { } function App() { + // Initialize theme from localStorage or default to dark + const [mode, setMode] = useState(() => { + const stored = localStorage.getItem('theme'); + return (stored === 'light' || stored === 'dark') ? stored : 'dark'; + }); + + const toggleTheme = () => { + setMode((prevMode) => { + const newMode = prevMode === 'dark' ? 'light' : 'dark'; + localStorage.setItem('theme', newMode); + return newMode; + }); + }; + + const theme = useMemo(() => createAppTheme(mode), [mode]); + + const themeContextValue = useMemo( + () => ({ mode, toggleTheme }), + [mode] + ); + return ( - - - - - }> - + + + + + + }> + {/* Public routes */} } /> } /> @@ -212,14 +254,6 @@ function App() { } /> - - - - } - /> - + + ); } diff --git a/ui/src/components/AdminPortalLayout.tsx b/ui/src/components/AdminPortalLayout.tsx index 92deb234..a0222351 100644 --- a/ui/src/components/AdminPortalLayout.tsx +++ b/ui/src/components/AdminPortalLayout.tsx @@ -61,7 +61,7 @@ interface AdminPortalLayoutProps { * Navigation Sections: * - Overview: Admin dashboard * - Content Management: Templates, Plugins, Repositories - * - User Management: Users, Groups, Quotas + * - User Management: Users, Groups * - System: Nodes, Integrations, Scaling, Compliance * * @component @@ -132,7 +132,6 @@ function AdminPortalLayout({ children }: AdminPortalLayoutProps) { items: [ { text: 'Users', icon: , path: '/admin/users' }, { text: 'Groups', icon: , path: '/admin/groups' }, - { text: 'User Quotas', icon: , path: '/admin/quotas' }, ], }, { diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 5dec0754..3008ac6c 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -94,7 +94,6 @@ function Layout({ children }: LayoutProps) { { text: 'My Applications', icon: , path: '/' }, { text: 'My Sessions', icon: , path: '/sessions' }, { text: 'Shared with Me', icon: , path: '/shared-sessions' }, - { text: 'Settings', icon: , path: '/settings' }, ]; const handleOpenAdminPortal = () => { @@ -126,7 +125,10 @@ function Layout({ children }: LayoutProps) { - + navigate('/settings')} + > diff --git a/ui/src/lib/api.ts b/ui/src/lib/api.ts index 56cec6b9..c6790021 100644 --- a/ui/src/lib/api.ts +++ b/ui/src/lib/api.ts @@ -123,6 +123,7 @@ export interface Repository { name: string; url: string; branch: string; + type: 'template' | 'plugin'; authType: string; lastSync?: string; templateCount: number; diff --git a/ui/src/pages/EnhancedRepositories.tsx b/ui/src/pages/EnhancedRepositories.tsx index 7a7440fa..979161e0 100644 --- a/ui/src/pages/EnhancedRepositories.tsx +++ b/ui/src/pages/EnhancedRepositories.tsx @@ -102,6 +102,7 @@ function EnhancedRepositoriesContent() { const [searchQuery, setSearchQuery] = useState(''); const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid'); const [statusFilter, setStatusFilter] = useState('all'); + const [typeFilter, setTypeFilter] = useState('all'); const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({ open: false, message: '', @@ -262,14 +263,17 @@ function EnhancedRepositoriesContent() { repo.url.toLowerCase().includes(searchQuery.toLowerCase()); const matchesStatus = statusFilter === 'all' || repo.status === statusFilter; + const matchesType = typeFilter === 'all' || repo.type === typeFilter; - return matchesSearch && matchesStatus; + return matchesSearch && matchesStatus && matchesType; }); const syncingCount = repositories.filter((r) => r.status === 'syncing').length; const syncedCount = repositories.filter((r) => r.status === 'synced').length; const failedCount = repositories.filter((r) => r.status === 'failed').length; - const totalTemplates = repositories.reduce((sum, r) => sum + r.templateCount, 0); + const templateRepoCount = repositories.filter((r) => r.type === 'template').length; + const pluginRepoCount = repositories.filter((r) => r.type === 'plugin').length; + const totalTemplates = repositories.filter((r) => r.type === 'template').reduce((sum, r) => sum + r.templateCount, 0); if (isLoading) { return ( @@ -288,10 +292,10 @@ function EnhancedRepositoriesContent() { - Template Repositories + Repositories - Manage external template repositories and sync status + Manage template and plugin repositories @@ -380,13 +384,13 @@ function EnhancedRepositoriesContent() { {/* Filters and View Controls */} - + setSearchQuery(e.target.value)} size="small" - sx={{ minWidth: 300 }} + sx={{ minWidth: 250 }} InputProps={{ startAdornment: ( @@ -396,12 +400,18 @@ function EnhancedRepositoriesContent() { }} /> - setStatusFilter(v)}> - - - - - + setTypeFilter(v)} sx={{ minHeight: 36 }}> + + + + + + setStatusFilter(v)} sx={{ minHeight: 36 }}> + + + + + diff --git a/ui/src/pages/UserSettings.tsx b/ui/src/pages/UserSettings.tsx index 75d41dba..f2ed16e7 100644 --- a/ui/src/pages/UserSettings.tsx +++ b/ui/src/pages/UserSettings.tsx @@ -44,6 +44,7 @@ import { useMFAMethods } from '../hooks/useApi'; import { useQueryClient } from '@tanstack/react-query'; import api from '../lib/api'; import { toast } from '../lib/toast'; +import { useThemeMode } from '../App'; /** * UserSettings - User account settings and preferences @@ -62,6 +63,7 @@ export default function UserSettings() { const { user } = useUserStore(); const queryClient = useQueryClient(); const { data: mfaMethods = [], isLoading: mfaLoading } = useMFAMethods(); + const { mode, toggleTheme } = useThemeMode(); // Password change state const [passwordForm, setPasswordForm] = useState({ @@ -73,12 +75,6 @@ export default function UserSettings() { const [passwordSuccess, setPasswordSuccess] = useState(false); const [changingPassword, setChangingPassword] = useState(false); - // Theme state - const [darkMode, setDarkMode] = useState(() => { - const stored = localStorage.getItem('theme'); - return stored ? stored === 'dark' : true; // Default to dark - }); - // MFA setup state const [mfaDialogOpen, setMfaDialogOpen] = useState(false); const [mfaStep, setMfaStep] = useState(0); @@ -126,12 +122,7 @@ export default function UserSettings() { // Handle theme toggle const handleThemeToggle = () => { - const newTheme = !darkMode; - setDarkMode(newTheme); - localStorage.setItem('theme', newTheme ? 'dark' : 'light'); - // Note: Full theme switching would require updating the ThemeProvider in App.tsx - // For now, this saves the preference - toast.info(`Theme preference saved. Refresh to apply ${newTheme ? 'dark' : 'light'} mode.`); + toggleTheme(); }; // Start MFA setup @@ -217,12 +208,12 @@ export default function UserSettings() { } - label={darkMode ? 'Dark Mode' : 'Light Mode'} + label={mode === 'dark' ? 'Dark Mode' : 'Light Mode'} /> Choose your preferred color scheme for the interface. diff --git a/ui/src/pages/admin/Quotas.tsx b/ui/src/pages/admin/Quotas.tsx deleted file mode 100644 index ad65dd62..00000000 --- a/ui/src/pages/admin/Quotas.tsx +++ /dev/null @@ -1,532 +0,0 @@ -import { useState, useEffect } from 'react'; -import { - Box, - Typography, - Paper, - Table, - TableBody, - TableCell, - TableContainer, - TableHead, - TableRow, - Button, - IconButton, - Dialog, - DialogTitle, - DialogContent, - DialogActions, - TextField, - Alert, - LinearProgress, - Tooltip, - CircularProgress, -} from '@mui/material'; -import { - Add as AddIcon, - Edit as EditIcon, - Delete as DeleteIcon, - Refresh as RefreshIcon, - Warning as WarningIcon, -} from '@mui/icons-material'; -import AdminPortalLayout from '../../components/AdminPortalLayout'; -import { api, UserQuota, SetQuotaRequest } from '../../lib/api'; -import { useQuotaEvents } from '../../hooks/useEnterpriseWebSocket'; -import { useNotificationQueue } from '../../components/NotificationQueue'; -import EnhancedWebSocketStatus from '../../components/EnhancedWebSocketStatus'; -import WebSocketErrorBoundary from '../../components/WebSocketErrorBoundary'; - -/** - * AdminQuotas - User resource quota management for administrators - * - * Administrative interface for managing user resource quotas across the platform. - * Administrators can set and modify resource limits for individual users to control - * session creation, CPU, memory, and storage usage. Provides real-time quota monitoring - * with usage visualization and automatic alerts for quota violations. - * - * Features: - * - Create and edit user quotas - * - Set limits for sessions, CPU, memory, storage - * - Visual usage indicators with progress bars - * - Real-time quota usage tracking - * - Quota violation warnings - * - Delete quota configurations - * - Real-time quota event notifications via WebSocket - * - * Administrative capabilities: - * - Define per-user resource limits - * - Monitor quota utilization in real-time - * - Prevent resource overconsumption - * - Receive alerts for quota violations - * - Enforce fair resource allocation - * - Audit quota changes - * - * Resource types: - * - Sessions: Maximum concurrent sessions (integer) - * - CPU: Maximum CPU allocation (e.g., "4000m" = 4 cores) - * - Memory: Maximum memory allocation (e.g., "8Gi" = 8 gigabytes) - * - Storage: Maximum persistent storage (e.g., "50Gi" = 50 gigabytes) - * - * Real-time features: - * - Live quota usage updates - * - Quota exceeded notifications (high priority) - * - Quota warning alerts (>90% usage) - * - Create/update/delete notifications - * - WebSocket connection monitoring - * - * User workflows: - * - Create quotas for new users - * - Update quotas based on requirements - * - Monitor user resource consumption - * - Identify users approaching limits - * - Delete quotas to remove restrictions - * - * @page - * @route /admin/quotas - User resource quota management - * @access admin - Restricted to administrators only - * - * @component - * - * @returns {JSX.Element} Quota management interface with usage visualization - * - * @example - * // Route configuration: - * } /> - * - * @see Users for user account management - * @see AdminDashboard for overall resource utilization - */ -export default function AdminQuotas() { - const [quotas, setQuotas] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(''); - const [editDialogOpen, setEditDialogOpen] = useState(false); - const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); - const [selectedQuota, setSelectedQuota] = useState(null); - - // WebSocket connection state - const [wsConnected, setWsConnected] = useState(false); - - // Enhanced notification system - const { addNotification } = useNotificationQueue(); - - // Real-time quota events via WebSocket with notifications - useQuotaEvents((data: any) => { - console.log('Quota event:', data); - setWsConnected(true); - - // Show notifications for quota events - if (data.event_type === 'quota.created') { - addNotification({ - message: `Quota created for user: ${data.username}`, - severity: 'info', - priority: 'medium', - title: 'Quota Created', - }); - // Refresh quota list - loadQuotas(); - } else if (data.event_type === 'quota.updated') { - addNotification({ - message: `Quota updated for user ${data.username}`, - severity: 'info', - priority: 'low', - title: 'Quota Updated', - }); - // Refresh quota list - loadQuotas(); - } else if (data.event_type === 'quota.deleted') { - addNotification({ - message: `Quota deleted for user ${data.username}`, - severity: 'warning', - priority: 'medium', - title: 'Quota Deleted', - }); - // Refresh quota list - loadQuotas(); - } else if (data.event_type === 'quota.exceeded') { - addNotification({ - message: `User ${data.username} has exceeded ${data.resource_type} quota`, - severity: 'warning', - priority: 'high', - title: 'Quota Exceeded', - }); - } else if (data.event_type === 'quota.warning') { - addNotification({ - message: `User ${data.username} is approaching ${data.resource_type} quota limit (${data.percentage}%)`, - severity: 'warning', - priority: 'medium', - title: 'Quota Warning', - }); - } - }); - - // Form state - const [username, setUsername] = useState(''); - const [maxSessions, setMaxSessions] = useState('10'); - const [maxCPU, setMaxCPU] = useState('4000m'); - const [maxMemory, setMaxMemory] = useState('8Gi'); - const [maxStorage, setMaxStorage] = useState('50Gi'); - - useEffect(() => { - loadQuotas(); - }, []); - - const loadQuotas = async () => { - setLoading(true); - setError(''); - - try { - const quotasData = await api.listAllUserQuotas(); - // Ensure quotasData is always an array to prevent undefined errors - setQuotas(Array.isArray(quotasData) ? quotasData : []); - } catch (err: any) { - console.error('Failed to load quotas:', err); - setError(err.response?.data?.message || 'Failed to load user quotas'); - // Set empty array on error to prevent undefined - setQuotas([]); - } finally { - setLoading(false); - } - }; - - const handleOpenEdit = (quota?: UserQuota) => { - if (quota) { - setSelectedQuota(quota); - setUsername(quota.username || quota.userId); - setMaxSessions(quota.maxSessions.toString()); - setMaxCPU(quota.maxCpu); - setMaxMemory(quota.maxMemory); - setMaxStorage(quota.maxStorage); - } else { - setSelectedQuota(null); - setUsername(''); - setMaxSessions('10'); - setMaxCPU('4000m'); - setMaxMemory('8Gi'); - setMaxStorage('50Gi'); - } - setEditDialogOpen(true); - }; - - const handleSaveQuota = async () => { - if (!username.trim()) { - setError('Username is required'); - return; - } - - try { - const quotaData: SetQuotaRequest = { - username: username.trim(), - maxSessions: parseInt(maxSessions), - maxCpu: maxCPU, - maxMemory, - maxStorage, - }; - - await api.setAdminUserQuota(quotaData); - setEditDialogOpen(false); - loadQuotas(); - } catch (err: any) { - console.error('Failed to save quota:', err); - setError(err.response?.data?.message || 'Failed to save user quota'); - } - }; - - const handleDeleteQuota = async () => { - if (!selectedQuota) return; - - try { - await api.deleteAdminUserQuota(selectedQuota.username || selectedQuota.userId); - setDeleteDialogOpen(false); - setSelectedQuota(null); - loadQuotas(); - } catch (err: any) { - console.error('Failed to delete quota:', err); - setError(err.response?.data?.message || 'Failed to delete user quota'); - } - }; - - const calculatePercentage = (used: number, limit: number): number => { - if (limit === 0) return 0; - return (used / limit) * 100; - }; - - const parseResourceString = (resource: string): number => { - // Parse resource strings like "2Gi", "4000m", etc. - const match = resource.match(/^(\d+)([a-zA-Z]+)?$/); - if (!match) return 0; - - const value = parseInt(match[1]); - const unit = match[2] || ''; - - if (unit === 'Gi') return value * 1024; - if (unit === 'Mi') return value; - if (unit === 'm') return value; - - return value; - }; - - if (loading) { - return ( - - - - - - ); - } - - return ( - - - - - - - User Quotas - - - {/* Enhanced WebSocket Connection Status */} - - - - - - - - - {error && ( - setError('')}> - {error} - - )} - - - - - - Username - Sessions - CPU - Memory - Storage - Actions - - - - {quotas.length === 0 ? ( - - - - No user quotas configured - - - - ) : ( - quotas.map((quota) => { - const sessionPercent = calculatePercentage(quota?.usedSessions ?? 0, quota?.maxSessions ?? 0); - const cpuPercent = calculatePercentage( - parseResourceString(quota?.usedCpu || '0'), - parseResourceString(quota?.maxCpu || '0') - ); - const memoryPercent = calculatePercentage( - parseResourceString(quota?.usedMemory || '0'), - parseResourceString(quota?.maxMemory || '0') - ); - const storagePercent = calculatePercentage( - parseResourceString(quota?.usedStorage || '0'), - parseResourceString(quota?.maxStorage || '0') - ); - - return ( - - - - {quota.username || quota.userId} - - - - - - - {quota?.usedSessions ?? 0} / {quota?.maxSessions ?? 0} - - {sessionPercent > 90 && ( - - - - )} - - 90 ? '#f44336' : '#3f51b5', - }, - }} - /> - - - - - - {quota?.usedCpu || '0'} / {quota?.maxCpu || '0'} - - 90 ? '#f44336' : '#f50057', - }, - }} - /> - - - - - - {quota?.usedMemory || '0'} / {quota?.maxMemory || '0'} - - 90 ? '#f44336' : '#ff9800', - }, - }} - /> - - - - - - {quota?.usedStorage || '0'} / {quota?.maxStorage || '0'} - - 90 ? '#f44336' : '#4caf50', - }, - }} - /> - - - - handleOpenEdit(quota)}> - - - { - setSelectedQuota(quota); - setDeleteDialogOpen(true); - }} - > - - - - - ); - }) - )} - -
-
- - {/* Edit/Add Quota Dialog */} - setEditDialogOpen(false)} maxWidth="sm" fullWidth> - {selectedQuota ? 'Edit User Quota' : 'Add User Quota'} - - - setUsername(e.target.value)} - disabled={!!selectedQuota} - helperText={selectedQuota ? 'Username cannot be changed' : 'Enter the username'} - /> - setMaxSessions(e.target.value)} - helperText="Maximum number of concurrent sessions" - /> - setMaxCPU(e.target.value)} - helperText="e.g., 4000m (4 cores) or 8000m (8 cores)" - /> - setMaxMemory(e.target.value)} - helperText="e.g., 8Gi or 16Gi" - /> - setMaxStorage(e.target.value)} - helperText="e.g., 50Gi or 100Gi" - /> - - - - - - - - - {/* Delete Confirmation Dialog */} - setDeleteDialogOpen(false)}> - Delete User Quota - - Are you sure you want to delete the quota for user {selectedQuota?.username}? This action - cannot be undone. - - - - - - -
-
-
- ); -} diff --git a/ui/src/pages/admin/UserDetail.tsx b/ui/src/pages/admin/UserDetail.tsx index 0579908b..8c1a5f80 100644 --- a/ui/src/pages/admin/UserDetail.tsx +++ b/ui/src/pages/admin/UserDetail.tsx @@ -334,9 +334,14 @@ export default function UserDetail() { - - Resource Quota - + + + Resource Quota Override + + + User-specific limits that override group quotas + + {!editQuotaMode && quota && (