mirror of
https://github.com/umami-software/umami.git
synced 2026-02-04 04:37:11 +01:00
Add real-time session updates via Server-Sent Events
Implements push-based real-time updates for the Sessions page. New sessions now appear instantly without manual reload or polling. Changes: - Add SSE event emitter for session creation notifications - Create SSE stream endpoint at /api/websites/[id]/sessions/stream - Emit session events in tracking endpoint when sessions are created - Add useSessionStream hook to connect to SSE and invalidate queries - Fix LoadingPanel to prevent flicker during background refetches
This commit is contained in:
parent
81e27fc18c
commit
ef9a382cdd
7 changed files with 78 additions and 5 deletions
|
|
@ -1,8 +1,9 @@
|
||||||
import { DataGrid } from '@/components/common/DataGrid';
|
import { DataGrid } from '@/components/common/DataGrid';
|
||||||
import { useWebsiteSessionsQuery } from '@/components/hooks';
|
import { useSessionStream, useWebsiteSessionsQuery } from '@/components/hooks';
|
||||||
import { SessionsTable } from './SessionsTable';
|
import { SessionsTable } from './SessionsTable';
|
||||||
|
|
||||||
export function SessionsDataTable({ websiteId }: { websiteId?: string; teamId?: string }) {
|
export function SessionsDataTable({ websiteId }: { websiteId?: string; teamId?: string }) {
|
||||||
|
useSessionStream(websiteId);
|
||||||
const queryResult = useWebsiteSessionsQuery(websiteId);
|
const queryResult = useWebsiteSessionsQuery(websiteId);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ import { parseRequest } from '@/lib/request';
|
||||||
import { badRequest, forbidden, json, serverError } from '@/lib/response';
|
import { badRequest, forbidden, json, serverError } from '@/lib/response';
|
||||||
import { anyObjectParam, urlOrPathParam } from '@/lib/schema';
|
import { anyObjectParam, urlOrPathParam } from '@/lib/schema';
|
||||||
import { safeDecodeURI, safeDecodeURIComponent } from '@/lib/url';
|
import { safeDecodeURI, safeDecodeURIComponent } from '@/lib/url';
|
||||||
|
import { emitSessionCreated } from '@/lib/session-events';
|
||||||
import { createSession, saveEvent, saveSessionData } from '@/queries/sql';
|
import { createSession, saveEvent, saveSessionData } from '@/queries/sql';
|
||||||
|
|
||||||
interface Cache {
|
interface Cache {
|
||||||
|
|
@ -151,6 +152,7 @@ export async function POST(request: Request) {
|
||||||
distinctId: id,
|
distinctId: id,
|
||||||
createdAt,
|
createdAt,
|
||||||
});
|
});
|
||||||
|
emitSessionCreated(sourceId, sessionId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Visit info
|
// Visit info
|
||||||
|
|
|
||||||
31
src/app/api/websites/[websiteId]/sessions/stream/route.ts
Normal file
31
src/app/api/websites/[websiteId]/sessions/stream/route.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
import { subscribeToSessions } from '@/lib/session-events';
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ websiteId: string }> },
|
||||||
|
) {
|
||||||
|
const { websiteId } = await params;
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
|
||||||
|
const unsubscribe = subscribeToSessions(websiteId, (_, sessionId) => {
|
||||||
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify({ sessionId })}\n\n`));
|
||||||
|
});
|
||||||
|
|
||||||
|
request.signal.addEventListener('abort', () => {
|
||||||
|
unsubscribe();
|
||||||
|
controller.close();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'text/event-stream',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
Connection: 'keep-alive',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -28,9 +28,11 @@ export function LoadingPanel({
|
||||||
...props
|
...props
|
||||||
}: LoadingPanelProps): ReactNode {
|
}: LoadingPanelProps): ReactNode {
|
||||||
const empty = isEmpty ?? checkEmpty(data);
|
const empty = isEmpty ?? checkEmpty(data);
|
||||||
|
const hasData = data && !empty;
|
||||||
|
|
||||||
// Show loading spinner only if no data exists
|
// Show loading only on initial load when no data exists yet
|
||||||
if (isLoading || isFetching) {
|
// Don't show loading during background refetches when we already have data
|
||||||
|
if ((isLoading || isFetching) && !hasData) {
|
||||||
return (
|
return (
|
||||||
<Column position="relative" height="100%" width="100%" {...props}>
|
<Column position="relative" height="100%" width="100%" {...props}>
|
||||||
<Loading icon={loadingIcon} placement={loadingPlacement} />
|
<Loading icon={loadingIcon} placement={loadingPlacement} />
|
||||||
|
|
@ -48,8 +50,8 @@ export function LoadingPanel({
|
||||||
return renderEmpty();
|
return renderEmpty();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show main content when data exists
|
// Show content when we have data (even during background refetch)
|
||||||
if (!isLoading && !isFetching && !error && !empty) {
|
if (hasData) {
|
||||||
return children;
|
return children;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ export * from './useNavigation';
|
||||||
export * from './usePagedQuery';
|
export * from './usePagedQuery';
|
||||||
export * from './usePageParameters';
|
export * from './usePageParameters';
|
||||||
export * from './useRegionNames';
|
export * from './useRegionNames';
|
||||||
|
export * from './useSessionStream';
|
||||||
export * from './useSlug';
|
export * from './useSlug';
|
||||||
export * from './useSticky';
|
export * from './useSticky';
|
||||||
export * from './useTimezone';
|
export * from './useTimezone';
|
||||||
|
|
|
||||||
18
src/components/hooks/useSessionStream.ts
Normal file
18
src/components/hooks/useSessionStream.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useQueryClient } from '@tanstack/react-query';
|
||||||
|
|
||||||
|
export function useSessionStream(websiteId: string) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!websiteId) return;
|
||||||
|
|
||||||
|
const eventSource = new EventSource(`/api/websites/${websiteId}/sessions/stream`);
|
||||||
|
|
||||||
|
eventSource.onmessage = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['sessions'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => eventSource.close();
|
||||||
|
}, [websiteId, queryClient]);
|
||||||
|
}
|
||||||
18
src/lib/session-events.ts
Normal file
18
src/lib/session-events.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
type SessionListener = (websiteId: string, sessionId: string) => void;
|
||||||
|
|
||||||
|
const listeners = new Map<string, Set<SessionListener>>();
|
||||||
|
|
||||||
|
export function subscribeToSessions(websiteId: string, callback: SessionListener) {
|
||||||
|
if (!listeners.has(websiteId)) {
|
||||||
|
listeners.set(websiteId, new Set());
|
||||||
|
}
|
||||||
|
listeners.get(websiteId).add(callback);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
listeners.get(websiteId)?.delete(callback);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function emitSessionCreated(websiteId: string, sessionId: string) {
|
||||||
|
listeners.get(websiteId)?.forEach(cb => cb(websiteId, sessionId));
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue