From a58ac303e9b1658693b2b6f17c29f6242aabcda7 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Nov 2025 02:09:18 +0000 Subject: [PATCH 1/5] fix(api): exclude metrics endpoint from gzip compression The metrics API was returning garbled binary data because the gzip compression middleware was compressing responses but the browser wasn't consistently decompressing them. This adds /api/v1/metrics to the exclusion list alongside WebSocket and auth endpoints. --- api/cmd/main.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) 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) }, )) From ae1a5a4c207d12c6e02bd439985d24d5c448c04b Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Nov 2025 02:20:12 +0000 Subject: [PATCH 2/5] fix(ui): remove duplicate Settings entry and enable theme toggle - Remove duplicate Settings entry from sidebar menu items array - Wire up the remaining Settings entry (after divider) to navigate to /settings - Add ThemeContext to App.tsx for dynamic light/dark mode switching - Update UserSettings to use theme context instead of showing refresh message - Theme changes now apply immediately without page refresh --- ui/src/App.tsx | 106 ++++++++++++++++++++++++---------- ui/src/components/Layout.tsx | 6 +- ui/src/pages/UserSettings.tsx | 19 ++---- 3 files changed, 84 insertions(+), 47 deletions(-) diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 52936b3d..08527166 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'; @@ -53,34 +67,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 +148,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 */} } /> } /> @@ -363,7 +406,8 @@ function App() { enableHistory={true} maxHistorySize={50} /> - + + ); } 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/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. From 79770cf2f0ee0b11a150818f9996d7a9e333d885 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 18 Nov 2025 02:27:51 +0000 Subject: [PATCH 3/5] refactor(ui): move user quotas to user detail page User-specific quota overrides are now managed directly on the user detail page instead of a separate admin quotas page. This makes quotas group-level by default, with user-specific overrides available on the individual user's detail page. - Remove standalone admin Quotas page and route - Update UserDetail to show "Resource Quota Override" section - Update navigation to remove Quotas link - Clarify that quotas are group-level with optional user overrides --- ui/src/App.tsx | 9 - ui/src/components/AdminPortalLayout.tsx | 3 +- ui/src/pages/admin/Quotas.tsx | 532 ------------------------ ui/src/pages/admin/UserDetail.tsx | 13 +- 4 files changed, 10 insertions(+), 547 deletions(-) delete mode 100644 ui/src/pages/admin/Quotas.tsx diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 08527166..a9e21bc9 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -44,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')); @@ -255,14 +254,6 @@ function App() { } /> - - - - } - /> , path: '/admin/users' }, { text: 'Groups', icon: , path: '/admin/groups' }, - { text: 'User Quotas', icon: , path: '/admin/quotas' }, ], }, { 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 && (