From 0a73d24262a245d3acf29d573f42d46ded35abfb Mon Sep 17 00:00:00 2001 From: Babayaga Date: Sun, 5 Apr 2026 13:00:03 +0200 Subject: [PATCH] analytics --- packages/ui/src/App.tsx | 3 - packages/ui/src/lib/db.ts | 94 ++----------------- .../analytics/AnalyticsDashboard.tsx | 40 ++++---- .../analytics/AnalyticsMap.tsx | 0 .../analytics/AnalyticsMapView.tsx | 36 ++++--- .../analytics/AnalyticsOverview.tsx | 38 ++++---- .../src/modules/analytics/client-analytics.ts | 54 +++++++++++ .../{pages => modules}/analytics/gridUtils.ts | 0 .../src/{pages => modules}/analytics/index.ts | 0 packages/ui/src/pages/AdminPage.tsx | 2 +- 10 files changed, 110 insertions(+), 157 deletions(-) rename packages/ui/src/{pages => modules}/analytics/AnalyticsDashboard.tsx (95%) rename packages/ui/src/{pages => modules}/analytics/AnalyticsMap.tsx (100%) rename packages/ui/src/{pages => modules}/analytics/AnalyticsMapView.tsx (91%) rename packages/ui/src/{pages => modules}/analytics/AnalyticsOverview.tsx (95%) create mode 100644 packages/ui/src/modules/analytics/client-analytics.ts rename packages/ui/src/{pages => modules}/analytics/gridUtils.ts (100%) rename packages/ui/src/{pages => modules}/analytics/index.ts (100%) diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index a2e07f9b..954795e5 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -23,7 +23,6 @@ const GlobalDragDrop = React.lazy(() => import("@/components/GlobalDragDrop")); // Register all widgets on app boot registerAllWidgets(); - import Index from "./pages/Index"; import Auth from "./pages/Auth"; @@ -284,9 +283,7 @@ const App = () => { - - diff --git a/packages/ui/src/lib/db.ts b/packages/ui/src/lib/db.ts index a39671e5..0948687d 100644 --- a/packages/ui/src/lib/db.ts +++ b/packages/ui/src/lib/db.ts @@ -105,10 +105,11 @@ async function apiResponseJson(response: Response, endpoint: string): Promise return response.text() as unknown as T; } -/** Generic API client: `${serverUrl}${endpoint}`, auth headers, JSON error bodies when present */ export async function apiClient(endpoint: string, options: RequestInit = {}): Promise { const headers = await getAuthHeaders(); - const response = await fetch(`${serverUrl}${endpoint}`, { + const isAbsolute = endpoint.startsWith('http://') || endpoint.startsWith('https://'); + const url = isAbsolute ? endpoint : `${serverUrl}${endpoint}`; + const response = await fetch(url, { ...options, headers: { ...headers, @@ -118,10 +119,11 @@ export async function apiClient(endpoint: string, options: RequestInit = {}): return apiResponseJson(response, endpoint); } -/** Same as apiClient but returns null on HTTP 404 (no throw). */ export async function apiClientOr404(endpoint: string, options: RequestInit = {}): Promise { const headers = await getAuthHeaders(); - const response = await fetch(`${serverUrl}${endpoint}`, { + const isAbsolute = endpoint.startsWith('http://') || endpoint.startsWith('https://'); + const url = isAbsolute ? endpoint : `${serverUrl}${endpoint}`; + const response = await fetch(url, { ...options, headers: { ...headers, @@ -160,87 +162,3 @@ export const checkLikeStatus = async (userId: string, pictureId: string, client? return !!data; }); }; - - - -export const fetchAnalytics = async (options: { limit?: number, startDate?: string, endDate?: string, baseUrl?: string } = {}) => { - const { data: sessionData } = await defaultSupabase.auth.getSession(); - const token = sessionData.session?.access_token; - - if (!token) throw new Error('No active session'); - - const headers: HeadersInit = {}; - headers['Authorization'] = `Bearer ${token}`; - - const params = new URLSearchParams(); - if (options.limit) params.append('limit', String(options.limit)); - if (options.startDate) params.append('startDate', options.startDate); - if (options.endDate) params.append('endDate', options.endDate); - - const serverUrl = options.baseUrl || import.meta.env.VITE_SERVER_IMAGE_API_URL; - - const res = await fetch(`${serverUrl}/api/analytics?${params.toString()}`, { headers }); - - if (!res.ok) { - if (res.status === 403 || res.status === 401) { - throw new Error('Unauthorized'); - } - throw new Error(`Failed to fetch analytics: ${res.statusText}`); - } - - return await res.json(); -}; - -export const fetchAnalyticsOverview = async (options: { period?: string, tracking?: string, showBots?: boolean, showApi?: boolean, baseUrl?: string } = {}) => { - const { data: sessionData } = await defaultSupabase.auth.getSession(); - const token = sessionData.session?.access_token; - - if (!token) throw new Error('No active session'); - - const headers: HeadersInit = { Authorization: `Bearer ${token}` }; - - const params = new URLSearchParams(); - if (options.period) params.append('period', options.period); - if (options.tracking) params.append('tracking', options.tracking); - if (options.showBots === false) params.append('showBots', 'false'); - if (options.showApi === true) params.append('showApi', 'true'); - - const serverUrl = options.baseUrl || import.meta.env.VITE_SERVER_IMAGE_API_URL; - const res = await fetch(`${serverUrl}/api/analytics/overview?${params.toString()}`, { headers }); - - if (!res.ok) { - if (res.status === 403 || res.status === 401) throw new Error('Unauthorized'); - throw new Error(`Failed to fetch analytics overview: ${res.statusText}`); - } - - return await res.json(); -}; - -export const clearAnalytics = async (baseUrl?: string) => { - const { data: sessionData } = await defaultSupabase.auth.getSession(); - const token = sessionData.session?.access_token; - - if (!token) throw new Error('No active session'); - - const headers: HeadersInit = {}; - headers['Authorization'] = `Bearer ${token}`; - - const serverUrl = baseUrl || import.meta.env.VITE_SERVER_IMAGE_API_URL; - - const res = await fetch(`${serverUrl}/api/analytics`, { - method: 'DELETE', - headers - }); - - if (!res.ok) { - if (res.status === 403 || res.status === 401) { - throw new Error('Unauthorized'); - } - throw new Error(`Failed to clear analytics: ${res.statusText}`); - } - - return await res.json(); -}; - - - diff --git a/packages/ui/src/pages/analytics/AnalyticsDashboard.tsx b/packages/ui/src/modules/analytics/AnalyticsDashboard.tsx similarity index 95% rename from packages/ui/src/pages/analytics/AnalyticsDashboard.tsx rename to packages/ui/src/modules/analytics/AnalyticsDashboard.tsx index 795e358e..dd7068c1 100644 --- a/packages/ui/src/pages/analytics/AnalyticsDashboard.tsx +++ b/packages/ui/src/modules/analytics/AnalyticsDashboard.tsx @@ -32,7 +32,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; -import { fetchAnalytics, clearAnalytics } from '@/lib/db'; +import { fetchAnalytics, clearAnalytics, subscribeToAnalyticsStream } from '@/modules/analytics/client-analytics'; import { supabase } from '@/integrations/supabase/client'; import { filterModelToParams, @@ -227,34 +227,28 @@ const AnalyticsDashboard = () => { setData(events.map((e: any, index: number) => ({ ...e, id: `init-${index}` }))); - // Start Streaming (with auth token as query param — EventSource doesn't support headers) - const { data: sessionData } = await supabase.auth.getSession(); - const token = sessionData.session?.access_token; - const streamBase = serverUrl || ''; - const streamPath = '/api/analytics/stream'; - const streamUrl = token - ? `${streamBase}${streamPath}?token=${encodeURIComponent(token)}` - : `${streamBase}${streamPath}`; - eventSource = new EventSource(streamUrl); - - eventSource.addEventListener('log', (event: any) => { - if (!isMounted) return; - try { - const newEntry = JSON.parse(event.data); + // Start Streaming + try { + eventSource = await subscribeToAnalyticsStream((newEntry) => { + if (!isMounted) return; setData(prev => { // Add new entry, keep max 2000 rows const updated = [{ ...newEntry, id: `stream-${Date.now()}-${Math.random()}` }, ...prev]; return updated.slice(0, 2000); }); - } catch (e) { - console.error('Failed to parse SSE event', e); + }, serverUrl || undefined); + + if (!isMounted) { + eventSource.close(); + } else { + eventSource.onerror = (err) => { + console.error('SSE Error:', err); + if (eventSource) eventSource.close(); + }; } - }); - - eventSource.onerror = (err) => { - console.error('SSE Error:', err); - if (eventSource) eventSource.close(); - }; + } catch (err) { + console.error('Failed to subscribe to analytics stream', err); + } } catch (error) { console.error("Failed to load analytics", error); diff --git a/packages/ui/src/pages/analytics/AnalyticsMap.tsx b/packages/ui/src/modules/analytics/AnalyticsMap.tsx similarity index 100% rename from packages/ui/src/pages/analytics/AnalyticsMap.tsx rename to packages/ui/src/modules/analytics/AnalyticsMap.tsx diff --git a/packages/ui/src/pages/analytics/AnalyticsMapView.tsx b/packages/ui/src/modules/analytics/AnalyticsMapView.tsx similarity index 91% rename from packages/ui/src/pages/analytics/AnalyticsMapView.tsx rename to packages/ui/src/modules/analytics/AnalyticsMapView.tsx index d0e12ccf..8f3b911d 100644 --- a/packages/ui/src/pages/analytics/AnalyticsMapView.tsx +++ b/packages/ui/src/modules/analytics/AnalyticsMapView.tsx @@ -1,6 +1,5 @@ import React, { useEffect, useState, useCallback, useRef, lazy, Suspense } from 'react'; -import { fetchAnalyticsOverview } from '@/lib/db'; -import { supabase } from '@/integrations/supabase/client'; +import { fetchAnalyticsOverview, subscribeToAnalyticsStream } from '@/modules/analytics/client-analytics'; import { Button } from '@/components/ui/button'; import { Globe, Bot, Loader2, RefreshCw @@ -71,20 +70,9 @@ const AnalyticsMapView: React.FC = ({ serverUrl }) => { let mounted = true; (async () => { - const { data: session } = await supabase.auth.getSession(); - const token = session.session?.access_token || ''; - const base = serverUrl || import.meta.env.VITE_SERVER_IMAGE_API_URL || ''; - const url = token - ? `${base}/api/analytics/stream?token=${encodeURIComponent(token)}` - : `${base}/api/analytics/stream`; - - const es = new EventSource(url); - esRef.current = es; - - es.addEventListener('log', (event: any) => { - if (!mounted) return; - try { - const entry = JSON.parse(event.data); + try { + const es = await subscribeToAnalyticsStream((entry) => { + if (!mounted) return; if (!showBots && (entry.isBot || entry.isAI)) return; if (!showApi && (entry.path || '').startsWith('/api/')) return; @@ -109,10 +97,18 @@ const AnalyticsMapView: React.FC = ({ serverUrl }) => { return { ...prev, locations: locs }; }); } - } catch { /* ignore */ } - }); - - es.onerror = () => { if (es) es.close(); }; + }, serverUrl || undefined); + + if (!mounted) { + es.close(); + return; + } + + esRef.current = es; + es.onerror = () => { if (es) es.close(); }; + } catch (err) { + console.error('Failed to subscribe to analytics map stream:', err); + } })(); return () => { diff --git a/packages/ui/src/pages/analytics/AnalyticsOverview.tsx b/packages/ui/src/modules/analytics/AnalyticsOverview.tsx similarity index 95% rename from packages/ui/src/pages/analytics/AnalyticsOverview.tsx rename to packages/ui/src/modules/analytics/AnalyticsOverview.tsx index 025a0a60..f8e2f25c 100644 --- a/packages/ui/src/pages/analytics/AnalyticsOverview.tsx +++ b/packages/ui/src/modules/analytics/AnalyticsOverview.tsx @@ -1,8 +1,7 @@ import React, { useEffect, useState, useCallback, useRef, useMemo } from 'react'; import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; -import { fetchAnalyticsOverview } from '@/lib/db'; -import { supabase } from '@/integrations/supabase/client'; +import { fetchAnalyticsOverview, subscribeToAnalyticsStream } from '@/modules/analytics/client-analytics'; import { Clock, Users, Bot, BarChart3, Globe, TrendingUp, Loader2, RefreshCw, Eye, EyeOff @@ -128,20 +127,9 @@ const AnalyticsOverview: React.FC = ({ serverUrl }) => { let mounted = true; (async () => { - const { data: sessionData } = await supabase.auth.getSession(); - const token = sessionData.session?.access_token; - const base = serverUrl || ''; - const url = token - ? `${base}/api/analytics/stream?token=${encodeURIComponent(token)}` - : `${base}/api/analytics/stream`; - - const es = new EventSource(url); - esRef.current = es; - - es.addEventListener('log', (event: any) => { - if (!mounted) return; - try { - const entry = JSON.parse(event.data); + try { + const es = await subscribeToAnalyticsStream((entry) => { + if (!mounted) return; const isBot = entry.isBot === true || entry.isAI === true; if (!showBots && isBot) return; if (!showApi && (entry.path || '').startsWith('/api/')) return; @@ -150,7 +138,6 @@ const AnalyticsOverview: React.FC = ({ serverUrl }) => { // Determine bucket key (hourly) const d = new Date(entry.timestamp); const bk = `${d.getFullYear()}-${String(d.getMonth()+1).padStart(2,'0')}-${String(d.getDate()).padStart(2,'0')}T${String(d.getHours()).padStart(2,'0')}:00`; - const ip: string = entry.ip || 'unknown'; const p: string = entry.path || ''; const content = isContentVisit(p); @@ -173,13 +160,20 @@ const AnalyticsOverview: React.FC = ({ serverUrl }) => { // Update KPIs const kpis = { ...prev.kpis }; - if (isBot) kpis.uniqueBots = kpis.uniqueBots; // can't track unique client-side, keep server value return { ...prev, timeSeries: ts, kpis }; }); - } catch (e) { /* ignore */ } - }); - - es.onerror = () => { if (es) es.close(); }; + }, serverUrl || undefined); + + if (!mounted) { + es.close(); + return; + } + + esRef.current = es; + es.onerror = () => { if (es) es.close(); }; + } catch (err) { + console.error('Failed to subscribe to analytics overview stream:', err); + } })(); return () => { diff --git a/packages/ui/src/modules/analytics/client-analytics.ts b/packages/ui/src/modules/analytics/client-analytics.ts new file mode 100644 index 00000000..c4c5adcd --- /dev/null +++ b/packages/ui/src/modules/analytics/client-analytics.ts @@ -0,0 +1,54 @@ +import { apiClient, getAuthHeaders, serverUrl as defaultServerUrl, getAuthToken } from '@/lib/db'; + +export const fetchAnalytics = async (options: { limit?: number, startDate?: string, endDate?: string, baseUrl?: string } = {}) => { + const params = new URLSearchParams(); + if (options.limit) params.append('limit', String(options.limit)); + if (options.startDate) params.append('startDate', options.startDate); + if (options.endDate) params.append('endDate', options.endDate); + + const query = params.toString() ? `?${params.toString()}` : ''; + const base = options.baseUrl && options.baseUrl !== defaultServerUrl ? options.baseUrl : ''; + + return apiClient(`${base}/api/analytics${query}`); +}; + +export const fetchAnalyticsOverview = async (options: { period?: string, tracking?: string, showBots?: boolean, showApi?: boolean, baseUrl?: string } = {}) => { + const params = new URLSearchParams(); + if (options.period) params.append('period', options.period); + if (options.tracking) params.append('tracking', options.tracking); + if (options.showBots === false) params.append('showBots', 'false'); + if (options.showApi === true) params.append('showApi', 'true'); + + const query = params.toString() ? `?${params.toString()}` : ''; + const base = options.baseUrl && options.baseUrl !== defaultServerUrl ? options.baseUrl : ''; + + return apiClient(`${base}/api/analytics/overview${query}`); +}; + +export const clearAnalytics = async (baseUrl?: string) => { + const base = baseUrl && baseUrl !== defaultServerUrl ? baseUrl : ''; + return apiClient(`${base}/api/analytics`, { method: 'DELETE' }); +}; + +export const subscribeToAnalyticsStream = async ( + onLog: (entry: any) => void, + baseUrl?: string +): Promise => { + const token = await getAuthToken(); + const base = baseUrl || defaultServerUrl; + + const url = token + ? `${base}/api/analytics/stream?token=${encodeURIComponent(token)}` + : `${base}/api/analytics/stream`; + + const es = new EventSource(url); + + es.addEventListener('log', (event: any) => { + try { + const entry = JSON.parse(event.data); + onLog(entry); + } catch { /* ignore */ } + }); + + return es; +}; diff --git a/packages/ui/src/pages/analytics/gridUtils.ts b/packages/ui/src/modules/analytics/gridUtils.ts similarity index 100% rename from packages/ui/src/pages/analytics/gridUtils.ts rename to packages/ui/src/modules/analytics/gridUtils.ts diff --git a/packages/ui/src/pages/analytics/index.ts b/packages/ui/src/modules/analytics/index.ts similarity index 100% rename from packages/ui/src/pages/analytics/index.ts rename to packages/ui/src/modules/analytics/index.ts diff --git a/packages/ui/src/pages/AdminPage.tsx b/packages/ui/src/pages/AdminPage.tsx index edb926ba..deb40910 100644 --- a/packages/ui/src/pages/AdminPage.tsx +++ b/packages/ui/src/pages/AdminPage.tsx @@ -17,7 +17,7 @@ import React, { Suspense } from "react"; import { Routes, Route, Navigate } from "react-router-dom"; // Lazy load AnalyticsDashboard -const AnalyticsDashboard = React.lazy(() => import("@/pages/analytics").then(module => ({ default: module.AnalyticsDashboard }))); +const AnalyticsDashboard = React.lazy(() => import("@/modules/analytics").then(module => ({ default: module.AnalyticsDashboard }))); const AdminPage = () => { const { user, session, loading, roles } = useAuth();