diff --git a/ui/src/components/QuotaCard.tsx b/ui/src/components/QuotaCard.tsx index 46a23e32..0bdb344b 100644 --- a/ui/src/components/QuotaCard.tsx +++ b/ui/src/components/QuotaCard.tsx @@ -16,8 +16,6 @@ import { Workspaces as SessionsIcon, Warning as WarningIcon, } from '@mui/icons-material'; -import type { UserQuota as _UserQuota } from '../lib/api'; -void (_UserQuota); // Type-only import marked as used import { useCurrentUserQuota } from '../hooks/useApi'; interface QuotaMetric { diff --git a/ui/src/lib/toast.ts b/ui/src/lib/toast.ts index ba8d0270..7b2ff91a 100644 --- a/ui/src/lib/toast.ts +++ b/ui/src/lib/toast.ts @@ -10,6 +10,16 @@ interface ToastOptions { class ToastManager { private container: HTMLElement | null = null; private toasts: Map }> = new Map(); + // Dedupe identical toast messages fired within this window. Without this, + // a single page navigation that triggers N parallel API requests all + // returning 5xx (e.g. Sessions page hitting /sessions/:id/connect plus + // /users/me/quota during a brief backend hiccup) stacks the same red + // "Server error. Please try again later." 3-4× simultaneously, which + // looks like the app is dying. 2 seconds is long enough to absorb a + // request burst but short enough that a real second occurrence still + // shows up. + private recentMessages: Map = new Map(); + private static readonly DEDUPE_WINDOW_MS = 2000; private ensureContainer() { if (!this.container) { @@ -57,6 +67,23 @@ class ToastManager { } show(message: string, type: ToastType = 'info', options: ToastOptions = {}) { + // Dedupe: skip if this exact message was shown within the recent window. + // Keyed by `${type}:${message}` so an info and an error with the same + // text are still both shown. + const dedupeKey = `${type}:${message}`; + const now = Date.now(); + const last = this.recentMessages.get(dedupeKey); + if (last && now - last < ToastManager.DEDUPE_WINDOW_MS) { + return null; + } + this.recentMessages.set(dedupeKey, now); + // Garbage-collect old entries so the map doesn't grow unbounded. + for (const [key, ts] of this.recentMessages) { + if (now - ts > ToastManager.DEDUPE_WINDOW_MS * 2) { + this.recentMessages.delete(key); + } + } + const container = this.ensureContainer(); const id = `toast-${Date.now()}-${Math.random()}`; const duration = options.duration || 4000; diff --git a/ui/src/mocks/handlers.ts b/ui/src/mocks/handlers.ts index d85c1f63..659f948e 100644 --- a/ui/src/mocks/handlers.ts +++ b/ui/src/mocks/handlers.ts @@ -44,8 +44,8 @@ export const MOCK_SESSIONS = { }, activeConnections: 0, resources: { cpu: '500m', memory: '2Gi' }, - created_at: new Date().toISOString(), - last_activity: new Date().toISOString(), + createdAt: new Date().toISOString(), + lastActivity: new Date().toISOString(), }, hibernated: { name: 'test-session-hibernated', @@ -61,8 +61,8 @@ export const MOCK_SESSIONS = { }, activeConnections: 0, resources: { cpu: '500m', memory: '2Gi' }, - created_at: new Date().toISOString(), - last_activity: new Date().toISOString(), + createdAt: new Date().toISOString(), + lastActivity: new Date().toISOString(), }, }; @@ -117,9 +117,15 @@ export const handlers = [ http.post('/api/v1/auth/login', async ({ request }) => { const body = await request.json() as { username: string; password: string }; + // 24h expiry — matches what real backend returns; without this the + // auth store's isTokenExpired() returns true (null/undefined treated + // as already expired) and route guards bounce back to /login. + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + if (body.username === 'admin' && body.password === 'admin123') { return HttpResponse.json({ token: generateMockToken(MOCK_USERS.admin), + expiresAt, user: MOCK_USERS.admin, }); } @@ -127,6 +133,7 @@ export const handlers = [ if (body.username === 'testuser' && body.password === 'testuser123') { return HttpResponse.json({ token: generateMockToken(MOCK_USERS.testuser), + expiresAt, user: MOCK_USERS.testuser, }); } @@ -149,13 +156,15 @@ export const handlers = [ return HttpResponse.json({ message: 'Logged out successfully' }); }), - // Sessions endpoints + // Sessions endpoints. The real backend wraps the list in + // {"sessions": [...], "total": N} (see api/internal/api/handlers.go's + // ListSessions); this mock has to match that envelope or api.listSessions() + // returns undefined and the page silently renders empty. + // (Earlier bug: MOCK_SESSIONS.vnc was referenced here but never defined, + // producing a trailing `null` in the array; removed.) http.get('/api/v1/sessions', () => { - return HttpResponse.json([ - MOCK_SESSIONS.running, - MOCK_SESSIONS.hibernated, - MOCK_SESSIONS.vnc, - ]); + const sessions = [MOCK_SESSIONS.running, MOCK_SESSIONS.hibernated]; + return HttpResponse.json({ sessions, total: sessions.length }); }), http.get('/api/v1/sessions/:sessionId', ({ params }) => { @@ -180,7 +189,7 @@ export const handlers = [ ...MOCK_SESSIONS.running, name: body.name || `session-${Date.now()}`, template: body.template, - created_at: new Date().toISOString(), + createdAt: new Date().toISOString(), }; return HttpResponse.json(newSession, { status: 201 }); }), @@ -189,7 +198,9 @@ export const handlers = [ return HttpResponse.json({ status: 'terminated' }); }), - http.post('/api/v1/sessions/:sessionId/connect', () => { + // Backend route is GET /sessions/:id/connect (see api/cmd/main.go:637); the + // earlier POST mock 500'd in the SessionViewer flow. + http.get('/api/v1/sessions/:sessionId/connect', () => { return HttpResponse.json({ connectionId: `conn-${Date.now()}`, sessionUrl: 'http://test.local:3000', @@ -284,9 +295,29 @@ export const handlers = [ }); }), + // Cluster metrics — backs admin Dashboard's `useMetrics()` (api.getMetrics → GET /metrics). + // Shape mirrors the ClusterMetrics interface declared in admin/Dashboard.tsx. + http.get('/api/v1/metrics', () => { + return HttpResponse.json({ + cluster: { + nodes: { total: 3, ready: 3, notReady: 0 }, + sessions: { total: 2, running: 1, hibernated: 1, terminated: 0 }, + resources: { + cpu: { total: '12 cores', used: '3.2 cores', percent: 27 }, + memory: { total: '48 GiB', used: '14.5 GiB', percent: 30 }, + pods: { total: 110, used: 28, percent: 25 }, + }, + users: { total: 2, active: 1 }, + }, + }); + }), + // Users (admin) + // Real backend returns the {users, total} envelope (see api.listUsers); a bare + // array would deserialize as `[]` and the page would show "No users found". http.get('/api/v1/users', () => { - return HttpResponse.json([MOCK_USERS.admin, MOCK_USERS.testuser]); + const users = [MOCK_USERS.admin, MOCK_USERS.testuser]; + return HttpResponse.json({ users, total: users.length }); }), // System metrics (admin) @@ -298,4 +329,54 @@ export const handlers = [ uptime: '7 days', }); }), + + // --------------------------------------------------------------------------- + // Stub handlers — return empty arrays/objects so the UI doesn't crash with + // 500s on endpoints that aren't yet fully mocked. Pages that depend on these + // will render their empty states; that's the right behavior for a UI audit + // pass focused on layout/interaction bugs. + // --------------------------------------------------------------------------- + http.get('/api/v1/applications/user', () => HttpResponse.json([])), + http.get('/api/v1/preferences/favorites', () => HttpResponse.json([])), + http.put('/api/v1/preferences/favorites', () => HttpResponse.json({ success: true })), + http.get('/api/v1/plugins', () => HttpResponse.json([])), + http.get('/api/v1/plugins/catalog', () => HttpResponse.json([])), + http.get('/api/v1/catalog/repositories', () => HttpResponse.json([])), + http.get('/api/v1/catalog/templates', () => HttpResponse.json([])), + http.get('/api/v1/sessions/by-tags', () => HttpResponse.json([])), + http.get('/api/v1/groups', () => HttpResponse.json([])), + http.get('/api/v1/users/me', () => HttpResponse.json(MOCK_USERS.admin)), + http.get('/api/v1/users/me/quota', () => HttpResponse.json({ + maxSessions: 10, usedSessions: 1, maxCpu: '8000m', usedCpu: '1000m', + maxMemory: '16Gi', usedMemory: '2Gi', maxStorage: '100Gi', usedStorage: '5Gi', + })), + http.get('/api/v1/scheduling/sessions', () => HttpResponse.json([])), + http.get('/api/v1/scheduling/calendar/integrations', () => HttpResponse.json([])), + http.get('/api/v1/security/mfa/methods', () => HttpResponse.json([])), + http.get('/api/v1/security/ip-whitelist', () => HttpResponse.json([])), + http.get('/api/v1/security/alerts', () => HttpResponse.json([])), + http.get('/api/v1/security/device-posture', () => HttpResponse.json({})), + http.get('/api/v1/compliance/dashboard', () => HttpResponse.json({})), + http.get('/api/v1/compliance/policies', () => HttpResponse.json([])), + http.get('/api/v1/compliance/violations', () => HttpResponse.json([])), + http.get('/api/v1/compliance/frameworks', () => HttpResponse.json([])), + http.get('/api/v1/admin/nodes', () => HttpResponse.json([])), + http.get('/api/v1/admin/nodes/stats', () => HttpResponse.json({})), + http.get('/api/v1/admin/quotas', () => HttpResponse.json([])), + // Admin Agents page hits /admin/agents (not /agents) and expects the + // {agents, total, page, limit} envelope; bare array → 500 in axios layer. + http.get('/api/v1/admin/agents', () => + HttpResponse.json({ agents: MOCK_AGENTS, total: MOCK_AGENTS.length, page: 1, limit: 50 }) + ), + http.get('/api/v1/integrations/external', () => HttpResponse.json([])), + http.get('/api/v1/integrations/webhooks', () => HttpResponse.json([])), + http.get('/api/v1/integrations/events', () => HttpResponse.json([])), + http.get('/api/v1/scaling/autoscaling/policies', () => HttpResponse.json([])), + http.get('/api/v1/scaling/autoscaling/history', () => HttpResponse.json([])), + http.get('/api/v1/scaling/load-balancing/nodes', () => HttpResponse.json([])), + http.get('/api/v1/scaling/load-balancing/policies', () => HttpResponse.json([])), + http.get('/api/v1/auth/setup/status', () => HttpResponse.json({ setupComplete: true })), + http.get('/api/v1/version', () => HttpResponse.json({ version: 'dev' })), + http.get('/api/v1/health', () => HttpResponse.json({ status: 'ok' })), + http.get('/api/v1/metrics', () => HttpResponse.json({})), ]; diff --git a/ui/src/pages/Sessions.tsx b/ui/src/pages/Sessions.tsx index 0beb5261..29a830ae 100644 --- a/ui/src/pages/Sessions.tsx +++ b/ui/src/pages/Sessions.tsx @@ -35,7 +35,7 @@ import SessionInvitationDialog from '../components/SessionInvitationDialog'; import QuotaAlert from '../components/QuotaAlert'; import ActivityIndicator from '../components/ActivityIndicator'; import IdleTimer from '../components/IdleTimer'; -import { useUpdateSessionState, useDeleteSession } from '../hooks/useApi'; +import { useSessions, useUpdateSessionState, useDeleteSession } from '../hooks/useApi'; import { useSessionsWebSocket } from '../hooks/useWebSocket'; import { useUserStore } from '../store/userStore'; import { Session } from '../lib/api'; @@ -95,6 +95,10 @@ export default function Sessions() { const navigate = useNavigate(); const username = useUserStore((state) => state.user?.username); const [sessions, setSessions] = useState([]); + // Initial REST fetch — seeds `sessions` so the page isn't blank during the + // window between mount and the first WebSocket push (or forever if the WS + // can't connect, which used to leave Sessions silently empty). + const { data: initialSessions } = useSessions(username); const updateSessionState = useUpdateSessionState(); const deleteSession = useDeleteSession(); const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); @@ -141,6 +145,20 @@ export default function Sessions() { // Enhanced WebSocket with connection quality and manual reconnect const sessionsWs = useEnhancedWebSocket(baseSessionsWs); + // Seed sessions from the initial REST fetch on mount. We only set if local + // state is still empty so a faster WebSocket push wins the race; if WS never + // connects (mock mode, network glitch), the page still renders the data we + // got from REST instead of being permanently empty. + useEffect(() => { + if (initialSessions && sessions.length === 0) { + const filtered = username + ? initialSessions.filter((s) => s.user === username) + : initialSessions; + setSessions(filtered); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialSessions]); + const handleStateChange = (id: string, state: 'running' | 'hibernated') => { updateSessionState.mutate({ id, state }); }; @@ -297,8 +315,15 @@ export default function Sessions() { No sessions found with the selected tag. Clear the filter to see all sessions. ) : sessions.length === 0 ? ( - - You don't have any sessions yet. Visit the Template Catalog to create one! + navigate('/')}> + Browse Applications + + } + > + You don't have any sessions yet. Pick an application to launch one. ) : ( @@ -306,25 +331,45 @@ export default function Sessions() { - - - + + + {session.template} - + {session.name} - - - - - + {/* Single status pill — phase is the most informative + (Running/Pending/Hibernated/Failed); the lowercase + `state` Chip and a separate ActivityIndicator pill + were stacked here previously, three pills for the + same conceptual thing. ActivityIndicator now only + appears inline below when the session is idle. */} + @@ -356,15 +401,39 @@ export default function Sessions() { )} {session.status.url && ( - - + + URL - + {session.status.url} )} + {/* Idle indicator only when actively idle — not as a + third stacked status pill. */} + {session.isIdle && session.state === 'running' && ( + + + + )} {session.tags && session.tags.length > 0 && ( @@ -379,8 +448,13 @@ export default function Sessions() { )} - - + {/* Action row: previously wrapped inconsistently (Connect on + its own line on narrow cards). flexWrap + gap keeps the + primary action (Connect/Resume) and the icon row on the + same line at sensible card widths and wraps cleanly when + they don't fit. */} + + {session.state === 'running' ? ( <>