Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 0 additions & 2 deletions ui/src/components/QuotaCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
27 changes: 27 additions & 0 deletions ui/src/lib/toast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,16 @@ interface ToastOptions {
class ToastManager {
private container: HTMLElement | null = null;
private toasts: Map<string, { element: HTMLElement; timeout: ReturnType<typeof setTimeout> }> = 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<string, number> = new Map();
private static readonly DEDUPE_WINDOW_MS = 2000;

private ensureContainer() {
if (!this.container) {
Expand Down Expand Up @@ -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;
Expand Down
107 changes: 94 additions & 13 deletions ui/src/mocks/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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(),
},
};

Expand Down Expand Up @@ -117,16 +117,23 @@ 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,
});
}

if (body.username === 'testuser' && body.password === 'testuser123') {
return HttpResponse.json({
token: generateMockToken(MOCK_USERS.testuser),
expiresAt,
user: MOCK_USERS.testuser,
});
}
Expand All @@ -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 }) => {
Expand All @@ -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 });
}),
Expand All @@ -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',
Expand Down Expand Up @@ -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)
Expand All @@ -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({})),
];
Loading
Loading