analytics
This commit is contained in:
parent
7383b40b27
commit
0a73d24262
@ -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 = () => {
|
||||
</DragDropProvider>
|
||||
</BrowserRouter>
|
||||
</ActionProvider>
|
||||
|
||||
</TooltipProvider>
|
||||
|
||||
</MediaRefreshProvider>
|
||||
</LogProvider>
|
||||
</AuthProvider>
|
||||
|
||||
@ -105,10 +105,11 @@ async function apiResponseJson<T>(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<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
|
||||
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<T>(endpoint: string, options: RequestInit = {}):
|
||||
return apiResponseJson<T>(response, endpoint);
|
||||
}
|
||||
|
||||
/** Same as apiClient but returns null on HTTP 404 (no throw). */
|
||||
export async function apiClientOr404<T>(endpoint: string, options: RequestInit = {}): Promise<T | null> {
|
||||
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();
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
@ -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);
|
||||
@ -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<Props> = ({ 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<Props> = ({ 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 () => {
|
||||
@ -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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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 () => {
|
||||
54
packages/ui/src/modules/analytics/client-analytics.ts
Normal file
54
packages/ui/src/modules/analytics/client-analytics.ts
Normal file
@ -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<any>(`${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<any>(`${base}/api/analytics/overview${query}`);
|
||||
};
|
||||
|
||||
export const clearAnalytics = async (baseUrl?: string) => {
|
||||
const base = baseUrl && baseUrl !== defaultServerUrl ? baseUrl : '';
|
||||
return apiClient<any>(`${base}/api/analytics`, { method: 'DELETE' });
|
||||
};
|
||||
|
||||
export const subscribeToAnalyticsStream = async (
|
||||
onLog: (entry: any) => void,
|
||||
baseUrl?: string
|
||||
): Promise<EventSource> => {
|
||||
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;
|
||||
};
|
||||
@ -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();
|
||||
|
||||
Loading…
Reference in New Issue
Block a user