Skip to content
Merged
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
3 changes: 2 additions & 1 deletion pkg/web/types/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ type WebConfig struct {

// AuthProviderURL is the canonical URL of a remote authenticatoor
// service. When set, API requests must carry a JWT verified against
// the service's JWKS, and the SPA loads <url>/client.js to drive
// the service's JWKS, and the SPA loads <url>/client-v2.js (the
// shared-session client with automatic token refresh) to drive
// login/logout. When empty, the API is unauthenticated.
AuthProviderURL string `yaml:"authProviderUrl" envconfig:"WEB_AUTH_PROVIDER_URL"`

Expand Down
66 changes: 37 additions & 29 deletions web-ui/src/api/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,8 @@ interface ApiResponse<T> {
data: T;
}

function getAuthHeaders(): Record<string, string> {
const authHeader = authStore.getAuthHeader();
async function getAuthHeaders(): Promise<Record<string, string>> {
const authHeader = await authStore.getAuthHeader();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
Expand All @@ -90,7 +90,7 @@ function getAuthHeaders(): Record<string, string> {
}

async function fetchAIApi<T>(endpoint: string, options?: RequestInit): Promise<T> {
const headers = getAuthHeaders();
const headers = await getAuthHeaders();

const response = await fetch(`${API_BASE}${endpoint}`, {
...options,
Expand Down Expand Up @@ -149,40 +149,48 @@ export function streamAISession(
onError: (error: Error) => void,
onComplete: () => void
): () => void {
const authHeader = authStore.getAuthHeader();
let url = `${API_BASE}/ai/chat/${sessionId}/stream`;

// Add auth token as query param for SSE (headers don't work well with EventSource)
if (authHeader) {
const token = authHeader.replace('Bearer ', '');
url += `?token=${encodeURIComponent(token)}`;
}
let eventSource: EventSource | null = null;
let closed = false;

// The fresh token comes from the auth client asynchronously; open the
// stream once it resolves (no-op if the caller already cleaned up).
void (async () => {
const token = await authStore.getAuthToken();
if (closed) return;

let url = `${API_BASE}/ai/chat/${sessionId}/stream`;
// Add auth token as query param for SSE (headers don't work well with EventSource)
if (token) {
url += `?token=${encodeURIComponent(token)}`;
}

const eventSource = new EventSource(url);
eventSource = new EventSource(url);

eventSource.addEventListener('update', (event) => {
try {
const session = JSON.parse(event.data) as AISession;
onUpdate(session);
eventSource.addEventListener('update', (event) => {
try {
const session = JSON.parse(event.data) as AISession;
onUpdate(session);

// Close connection when session is complete or errored
if (session.status === 'complete' || session.status === 'error') {
eventSource.close();
onComplete();
// Close connection when session is complete or errored
if (session.status === 'complete' || session.status === 'error') {
eventSource?.close();
onComplete();
}
} catch (err) {
onError(err instanceof Error ? err : new Error('Failed to parse session update'));
}
} catch (err) {
onError(err instanceof Error ? err : new Error('Failed to parse session update'));
}
});
});

eventSource.onerror = () => {
eventSource.close();
onError(new Error('Connection to session stream failed'));
};
eventSource.onerror = () => {
eventSource?.close();
onError(new Error('Connection to session stream failed'));
};
})();

// Return cleanup function
return () => {
eventSource.close();
closed = true;
eventSource?.close();
};
}

Expand Down
6 changes: 3 additions & 3 deletions web-ui/src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ async function fetchApi<T>(endpoint: string, options?: RequestInit): Promise<T>

// Fetch API with Authorization header for protected endpoints
async function fetchApiWithAuth<T>(endpoint: string, options?: RequestInit): Promise<T> {
const authHeader = authStore.getAuthHeader();
const authHeader = await authStore.getAuthHeader();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
...(options?.headers as Record<string, string>),
Expand Down Expand Up @@ -225,7 +225,7 @@ export async function deleteTest(testId: string): Promise<void> {
export async function registerTest(yaml: string): Promise<void> {
// Send raw YAML with application/yaml content type
// The backend expects either YAML body or JSON with test fields directly
const authHeader = authStore.getAuthHeader();
const authHeader = await authStore.getAuthHeader();
const headers: Record<string, string> = {
'Content-Type': 'application/yaml',
};
Expand Down Expand Up @@ -282,7 +282,7 @@ export async function getDashboardConfig(): Promise<unknown | null> {
}

export async function putDashboardConfig(cfg: unknown): Promise<void> {
const authHeader = authStore.getAuthHeader();
const authHeader = await authStore.getAuthHeader();
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
Expand Down
3 changes: 1 addition & 2 deletions web-ui/src/context/AuthContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,9 @@ interface AuthContextValue {
authEnabled: boolean;
isLoggedIn: boolean;
user: string | null;
token: string | null;
expiresAt: number | null;
loading: boolean;
getAuthHeader: () => string | null;
getAuthHeader: () => Promise<string | null>;
login: () => void;
logout: () => void;
refreshToken: () => Promise<void>;
Expand Down
2 changes: 1 addition & 1 deletion web-ui/src/hooks/useAuth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ export function useAuth() {
return unsubscribe;
}, []);

const getAuthHeader = useCallback((): string | null => {
const getAuthHeader = useCallback((): Promise<string | null> => {
return authStore.getAuthHeader();
}, []);

Expand Down
95 changes: 53 additions & 42 deletions web-ui/src/hooks/useEventStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
const queryClient = useQueryClient();
const eventSourceRef = useRef<EventSource | null>(null);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Invalidates in-flight async connects (token fetch) on reconnect/unmount.
const connectSeqRef = useRef(0);

const handleEvent = useCallback(
(event: SSEEvent) => {
Expand Down Expand Up @@ -89,54 +91,62 @@
reconnectTimeoutRef.current = null;
}

// Build URL based on whether we're watching a specific run
const authHeader = authStore.getAuthHeader();
const authToken = authHeader?.startsWith('Bearer ') ? authHeader.slice('Bearer '.length) : undefined;
const baseUrl = runId ? `/api/v1/test_run/${runId}/events` : '/api/v1/events/stream';
const url = authToken ? `${baseUrl}?token=${encodeURIComponent(authToken)}` : baseUrl;

const eventSource = new EventSource(url);
eventSourceRef.current = eventSource;

const handleMessage = (event: MessageEvent) => {
try {
const data: SSEEvent = JSON.parse(event.data);
handleEvent(data);
} catch (error) {
console.error('Failed to parse SSE event:', error);
}
};

eventSource.onmessage = handleMessage;

const eventTypes = [
'connected',
'test.started',
'test.completed',
'test.failed',
'task.created',
'task.started',
'task.progress',
'task.completed',
'task.failed',
'task.log',
];

eventTypes.forEach((eventType) => {
eventSource.addEventListener(eventType, handleMessage);
});

eventSource.onerror = () => {
eventSource.close();
// Reconnect after 5 seconds
reconnectTimeoutRef.current = setTimeout(connect, 5000);
};
const seq = ++connectSeqRef.current;

void (async () => {
// Fresh token from the auth client for the SSE query param
// (EventSource can't send headers).
const authToken = (await authStore.getAuthToken()) ?? undefined;
if (seq !== connectSeqRef.current) return; // superseded meanwhile

// Build URL based on whether we're watching a specific run
const baseUrl = runId ? `/api/v1/test_run/${runId}/events` : '/api/v1/events/stream';
const url = authToken ? `${baseUrl}?token=${encodeURIComponent(authToken)}` : baseUrl;

const eventSource = new EventSource(url);
eventSourceRef.current = eventSource;

const handleMessage = (event: MessageEvent) => {
try {
const data: SSEEvent = JSON.parse(event.data);
handleEvent(data);
} catch (error) {
console.error('Failed to parse SSE event:', error);
}
};

eventSource.onmessage = handleMessage;

const eventTypes = [
'connected',
'test.started',
'test.completed',
'test.failed',
'task.created',
'task.started',
'task.progress',
'task.completed',
'task.failed',
'task.log',
];

eventTypes.forEach((eventType) => {
eventSource.addEventListener(eventType, handleMessage);
});

eventSource.onerror = () => {
eventSource.close();
// Reconnect after 5 seconds
reconnectTimeoutRef.current = setTimeout(connect, 5000);
};
})();
}, [runId, handleEvent]);

useEffect(() => {
connect();

return () => {
connectSeqRef.current++; // cancel any in-flight async connect

Check warning on line 149 in web-ui/src/hooks/useEventStream.ts

View workflow job for this annotation

GitHub Actions / Run code checks / Run frontend checks

The ref value 'connectSeqRef.current' will likely have changed by the time this effect cleanup function runs. If this ref points to a node rendered by React, copy 'connectSeqRef.current' to a variable inside the effect, and use that variable in the cleanup function
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
Expand All @@ -147,6 +157,7 @@
}, [connect]);

const disconnect = useCallback(() => {
connectSeqRef.current++; // cancel any in-flight async connect
if (eventSourceRef.current) {
eventSourceRef.current.close();
eventSourceRef.current = null;
Expand Down
Loading