From 2f5174b3824ce529c860c8567975c2eff6865538 Mon Sep 17 00:00:00 2001 From: joshuaaferguson Date: Mon, 27 Apr 2026 22:57:31 -0700 Subject: [PATCH 1/5] =?UTF-8?q?fix(ui):=20UI=20audit=20batch=201=20?= =?UTF-8?q?=E2=80=94=20auth,=20sessions,=20toasts,=20mocks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four real bugs surfaced + fixed during a Playwright-driven audit pass through Login → Dashboard → My Sessions → Session Viewer. ui/src/store/userStore.ts — defensive expiresAt fallback TypeScript LoginResponse.expiresAt is required, but TS contracts aren't enforced at the network boundary. A backend (or MSW mock) returning {token, user} without expiresAt left the field undefined, isTokenExpired() returned true on the next render, and the route guard silently bounced the user back to /login with no error shown. Default to 24h ahead when missing so the session is at least usable until the next /auth/me call refreshes state. ui/src/pages/Sessions.tsx — initial REST fetch + correct empty-state Page relied entirely on useSessionsWebSocket() for data with no initial REST seed, so a failing WS (mock mode, network glitch, or any race) left the page silently empty forever. Wired up the existing useSessions() hook to seed `sessions` on mount; WS pushes still win the race when faster. Also fixed empty-state copy that pointed at a non-existent "Template Catalog" — there's no Catalog link in the sidebar. Replaced with a "Browse Applications" inline action that routes to the My Applications page (the actual launch surface). ui/src/lib/toast.ts — dedupe identical toasts within 2s window 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) stacked the same red "Server error. Please try again later." 3-4× simultaneously, which looked like the app was dying. 2s is long enough to absorb a request burst but short enough that a real second occurrence still shows up. ui/src/mocks/handlers.ts — mock infra completeness pass - /auth/login: include expiresAt (paired with userStore fix above) - /sessions: wrap in {sessions, total} envelope to match real backend response shape (api/internal/api/handlers.go ListSessions); bare-array response made api.listSessions() return undefined - Removed undefined MOCK_SESSIONS.vnc reference that produced a trailing null in the array - Added stub handlers for ~25 endpoints called by the UI but never mocked, returning [] / {} so pages render their empty states instead of stacking 500s from MSW's default unhandled-request behavior. Real backend returns real data; mocks just need to not crash the audit. Long-term follow-up: generate the MSW handlers + the API client from a single OpenAPI spec exposed by the Go backend so they can never drift. Three of the four bugs above are the same class — TS contract not enforced at the network boundary because mock and backend shapes were hand-maintained separately. --- ui/src/lib/toast.ts | 27 ++++++++++++++++ ui/src/mocks/handlers.ts | 66 +++++++++++++++++++++++++++++++++++---- ui/src/pages/Sessions.tsx | 31 ++++++++++++++++-- ui/src/store/userStore.ts | 15 +++++++-- 4 files changed, 128 insertions(+), 11 deletions(-) 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..a121a677 100644 --- a/ui/src/mocks/handlers.ts +++ b/ui/src/mocks/handlers.ts @@ -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 }) => { @@ -298,4 +307,49 @@ 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([])), + 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..c433f19b 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. ) : ( diff --git a/ui/src/store/userStore.ts b/ui/src/store/userStore.ts index 953c44d2..eab2d7ee 100644 --- a/ui/src/store/userStore.ts +++ b/ui/src/store/userStore.ts @@ -25,12 +25,23 @@ export const useUserStore = create()( expiresAt: null, isAuthenticated: false, - // Set authentication from login response + // Set authentication from login response. + // + // Defensive default for expiresAt: TypeScript marks LoginResponse.expiresAt + // as required, but TS type contracts aren't enforced at the network + // boundary. A backend (or MSW mock) returning {token, user} without + // expiresAt would otherwise leave the field undefined, isTokenExpired() + // would return true on the next render, and the route guard would bounce + // the user back to /login with no error shown — silent broken auth. + // Default to 24h ahead so the session is at least usable until the next + // /auth/me call refreshes state. setAuth: (loginResponse: LoginResponse) => set({ user: loginResponse.user, token: loginResponse.token, - expiresAt: loginResponse.expiresAt, + expiresAt: + loginResponse.expiresAt ?? + new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), isAuthenticated: true, }), From 351c4b85c091963453328a964ba1e371ef69d012 Mon Sep 17 00:00:00 2001 From: joshuaaferguson Date: Mon, 27 Apr 2026 23:03:06 -0700 Subject: [PATCH 2/5] fix(ui): consolidate Sessions card status indicators and fix overflow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Card layout had three accumulated bugs: 1. Three stacked status pills per card (state Chip + phase Chip + ActivityIndicator) all conveying the same conceptual state. Consolidated to a single phase Chip; ActivityIndicator now renders inline below only when isIdle && state === 'running'. 2. Long template names, session names, and URLs overflowed the card. Added flex ellipsis (minWidth: 0 on parent + overflow/textOverflow/ whiteSpace on the text node) with title= attributes for full value on hover. 3. CardActions wrapped inconsistently — Connect button on its own line on narrow cards. flexWrap: 'wrap' + gap keeps the row coherent and wraps cleanly when needed. Added title= to icon-only buttons for a11y. --- ui/src/pages/Sessions.tsx | 91 ++++++++++++++++++++++++++++++--------- 1 file changed, 71 insertions(+), 20 deletions(-) diff --git a/ui/src/pages/Sessions.tsx b/ui/src/pages/Sessions.tsx index c433f19b..29a830ae 100644 --- a/ui/src/pages/Sessions.tsx +++ b/ui/src/pages/Sessions.tsx @@ -331,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. */} + @@ -381,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 && ( @@ -404,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' ? ( <>