add analytics-view
This commit is contained in:
@@ -90,6 +90,26 @@ build-rag-view:
|
|||||||
changes:
|
changes:
|
||||||
- rag-view/**/*
|
- rag-view/**/*
|
||||||
|
|
||||||
|
build-analytics-view:
|
||||||
|
stage: build
|
||||||
|
image: node:22-alpine
|
||||||
|
cache:
|
||||||
|
key: "${CI_COMMIT_REF_SLUG}-analytics-view"
|
||||||
|
paths:
|
||||||
|
- analytics-view/node_modules
|
||||||
|
script:
|
||||||
|
- cd analytics-view
|
||||||
|
- npm ci
|
||||||
|
- npm run build
|
||||||
|
artifacts:
|
||||||
|
paths:
|
||||||
|
- analytics-view/dist
|
||||||
|
expire_in: 1h
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_BRANCH == "main"
|
||||||
|
changes:
|
||||||
|
- analytics-view/**/*
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════
|
||||||
# PUBLISH DOCKER IMAGES
|
# PUBLISH DOCKER IMAGES
|
||||||
# ══════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════
|
||||||
@@ -170,6 +190,25 @@ publish-rag-view:
|
|||||||
changes:
|
changes:
|
||||||
- rag-view/**/*
|
- rag-view/**/*
|
||||||
|
|
||||||
|
publish-analytics-view:
|
||||||
|
stage: publish
|
||||||
|
image: docker:27
|
||||||
|
services:
|
||||||
|
- docker:27-dind
|
||||||
|
variables:
|
||||||
|
DOCKER_TLS_CERTDIR: ""
|
||||||
|
before_script:
|
||||||
|
- echo "$CI_REGISTRY_PASSWORD" | docker login $CI_REGISTRY -u $CI_REGISTRY_USER --password-stdin
|
||||||
|
script:
|
||||||
|
- docker build -t $REGISTRY/analytics-view:${CI_COMMIT_SHORT_SHA} -t $REGISTRY/analytics-view:latest -f analytics-view/docker/Dockerfile analytics-view/
|
||||||
|
- docker push $REGISTRY/analytics-view:${CI_COMMIT_SHORT_SHA}
|
||||||
|
- docker push $REGISTRY/analytics-view:latest
|
||||||
|
needs: [build-analytics-view]
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_BRANCH == "main"
|
||||||
|
changes:
|
||||||
|
- analytics-view/**/*
|
||||||
|
|
||||||
# ══════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════
|
||||||
# DEPLOY TO VPS
|
# DEPLOY TO VPS
|
||||||
# ══════════════════════════════════════════════════════════
|
# ══════════════════════════════════════════════════════════
|
||||||
@@ -263,6 +302,25 @@ deploy-rag-view:
|
|||||||
docker image prune -af
|
docker image prune -af
|
||||||
ENDSSH
|
ENDSSH
|
||||||
|
|
||||||
|
deploy-analytics-view:
|
||||||
|
<<: *deploy_setup
|
||||||
|
needs: [publish-analytics-view]
|
||||||
|
rules:
|
||||||
|
- if: $CI_COMMIT_BRANCH == "main"
|
||||||
|
changes:
|
||||||
|
- analytics-view/**/*
|
||||||
|
script:
|
||||||
|
- |
|
||||||
|
ssh $VPS_USER@$VPS_HOST << ENDSSH
|
||||||
|
set -e
|
||||||
|
echo "$CI_REGISTRY_PASSWORD" | docker login registry.gitlab.com -u "$CI_REGISTRY_USER" --password-stdin
|
||||||
|
cd /opt/services
|
||||||
|
export CI_COMMIT_SHORT_SHA=${CI_COMMIT_SHORT_SHA}
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml pull analytics-view
|
||||||
|
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d analytics-view
|
||||||
|
docker image prune -af
|
||||||
|
ENDSSH
|
||||||
|
|
||||||
# Deploy all services at once (manual trigger)
|
# Deploy all services at once (manual trigger)
|
||||||
deploy-all:
|
deploy-all:
|
||||||
<<: *deploy_setup
|
<<: *deploy_setup
|
||||||
|
|||||||
10
analytics-view/.claude/settings.json
Normal file
10
analytics-view/.claude/settings.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"additionalDirectories": [
|
||||||
|
"C:\\Users\\balex\\IdeaProjects\\post-hub-platform"
|
||||||
|
],
|
||||||
|
"allow": [
|
||||||
|
"Bash(npm install:*)"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
24
analytics-view/.gitignore
vendored
Normal file
24
analytics-view/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
11
analytics-view/docker/Dockerfile
Normal file
11
analytics-view/docker/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY docker/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html/analyticsview
|
||||||
|
EXPOSE 80
|
||||||
8
analytics-view/docker/nginx.conf
Normal file
8
analytics-view/docker/nginx.conf
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
|
||||||
|
location /analyticsview/ {
|
||||||
|
try_files $uri $uri/ /analyticsview/index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
13
analytics-view/index.html
Normal file
13
analytics-view/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/analyticsview/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Analytics Viewer</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
4403
analytics-view/package-lock.json
generated
Normal file
4403
analytics-view/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
35
analytics-view/package.json
Normal file
35
analytics-view/package.json
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{
|
||||||
|
"name": "analytics-view",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.1.0",
|
||||||
|
"react-dom": "^19.1.0",
|
||||||
|
"react-router-dom": "^7.0.2",
|
||||||
|
"recharts": "^2.15.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.25.0",
|
||||||
|
"@tailwindcss/postcss": "^4.0.0",
|
||||||
|
"@types/react": "^19.1.0",
|
||||||
|
"@types/react-dom": "^19.1.0",
|
||||||
|
"@vitejs/plugin-react": "^4.4.1",
|
||||||
|
"autoprefixer": "^10.4.21",
|
||||||
|
"eslint": "^9.25.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.19",
|
||||||
|
"globals": "^16.0.0",
|
||||||
|
"postcss": "^8.5.3",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"typescript": "~5.8.3",
|
||||||
|
"typescript-eslint": "^8.29.1",
|
||||||
|
"vite": "^6.3.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
5
analytics-view/postcss.config.js
Normal file
5
analytics-view/postcss.config.js
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
38
analytics-view/src/App.tsx
Normal file
38
analytics-view/src/App.tsx
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import { Route, Routes } from "react-router-dom";
|
||||||
|
import { Dashboard } from "./components/Dashboard";
|
||||||
|
import { EventsTable } from "./components/EventsTable";
|
||||||
|
import { LoginForm } from "./components/LoginForm";
|
||||||
|
import { Navbar } from "./components/Navbar";
|
||||||
|
import { useAuth } from "./hooks/useAuth";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
const { auth, login, logout, refresh } = useAuth();
|
||||||
|
|
||||||
|
if (!auth) {
|
||||||
|
return (
|
||||||
|
<LoginForm
|
||||||
|
onLogin={async (email, password) => {
|
||||||
|
await login(email, password);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-slate-900">
|
||||||
|
<Navbar username={auth.username} onLogout={logout} />
|
||||||
|
<main className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-6">
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={<Dashboard token={auth.token} onUnauthorized={refresh} />}
|
||||||
|
/>
|
||||||
|
<Route
|
||||||
|
path="/events"
|
||||||
|
element={<EventsTable token={auth.token} onUnauthorized={refresh} />}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
75
analytics-view/src/api/analyticsApi.ts
Normal file
75
analytics-view/src/api/analyticsApi.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import type {
|
||||||
|
ActiveUser,
|
||||||
|
DailyStats,
|
||||||
|
DashboardStats,
|
||||||
|
EventFilters,
|
||||||
|
EventLog,
|
||||||
|
PageResponse,
|
||||||
|
} from "../types";
|
||||||
|
|
||||||
|
const API_BASE = `${import.meta.env.VITE_API_BASE_URL ?? ""}/api/analytics`;
|
||||||
|
|
||||||
|
async function apiFetch<T>(
|
||||||
|
path: string,
|
||||||
|
token: string,
|
||||||
|
onUnauthorized: () => Promise<string | null>,
|
||||||
|
): Promise<T> {
|
||||||
|
let res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
const newToken = await onUnauthorized();
|
||||||
|
if (!newToken) throw new Error("Unauthorized");
|
||||||
|
res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
headers: { Authorization: `Bearer ${newToken}` },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(`API error: ${res.status}`);
|
||||||
|
return res.json() as Promise<T>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDashboard(
|
||||||
|
token: string,
|
||||||
|
onUnauthorized: () => Promise<string | null>,
|
||||||
|
): Promise<DashboardStats> {
|
||||||
|
return apiFetch<DashboardStats>("/dashboard", token, onUnauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getActiveUsers(
|
||||||
|
token: string,
|
||||||
|
onUnauthorized: () => Promise<string | null>,
|
||||||
|
days = 7,
|
||||||
|
): Promise<ActiveUser[]> {
|
||||||
|
return apiFetch<ActiveUser[]>(`/users/active?days=${days}`, token, onUnauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDailyStats(
|
||||||
|
token: string,
|
||||||
|
onUnauthorized: () => Promise<string | null>,
|
||||||
|
days = 30,
|
||||||
|
): Promise<DailyStats[]> {
|
||||||
|
return apiFetch<DailyStats[]>(`/queries/daily?days=${days}`, token, onUnauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getEvents(
|
||||||
|
token: string,
|
||||||
|
onUnauthorized: () => Promise<string | null>,
|
||||||
|
filters: EventFilters,
|
||||||
|
): Promise<PageResponse<EventLog>> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("page", String(filters.page));
|
||||||
|
params.set("size", String(filters.size));
|
||||||
|
params.set("sort", `${filters.sortField},${filters.sortDir}`);
|
||||||
|
if (filters.eventType && filters.eventType !== "ALL") params.set("eventType", filters.eventType);
|
||||||
|
if (filters.userId) params.set("userId", filters.userId);
|
||||||
|
if (filters.dateFrom) params.set("dateFrom", filters.dateFrom);
|
||||||
|
if (filters.dateTo) params.set("dateTo", filters.dateTo);
|
||||||
|
|
||||||
|
return apiFetch<PageResponse<EventLog>>(
|
||||||
|
`/events?${params.toString()}`,
|
||||||
|
token,
|
||||||
|
onUnauthorized,
|
||||||
|
);
|
||||||
|
}
|
||||||
19
analytics-view/src/api/authApi.ts
Normal file
19
analytics-view/src/api/authApi.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { AuthResponse } from "../types";
|
||||||
|
|
||||||
|
const AUTH_BASE = `${import.meta.env.VITE_API_BASE_URL ?? ""}/api/auth`;
|
||||||
|
|
||||||
|
export async function login(email: string, password: string): Promise<AuthResponse> {
|
||||||
|
const res = await fetch(`${AUTH_BASE}/login`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ email, password }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Login failed: ${res.status}`);
|
||||||
|
return res.json() as Promise<AuthResponse>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function refreshToken(token: string): Promise<AuthResponse> {
|
||||||
|
const res = await fetch(`${AUTH_BASE}/refresh/token?token=${encodeURIComponent(token)}`);
|
||||||
|
if (!res.ok) throw new Error(`Refresh failed: ${res.status}`);
|
||||||
|
return res.json() as Promise<AuthResponse>;
|
||||||
|
}
|
||||||
184
analytics-view/src/components/Dashboard.tsx
Normal file
184
analytics-view/src/components/Dashboard.tsx
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from "recharts";
|
||||||
|
import { getDashboard, getActiveUsers, getDailyStats } from "../api/analyticsApi";
|
||||||
|
import type { ActiveUser, DailyStats, DashboardStats } from "../types";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
token: string;
|
||||||
|
onUnauthorized: () => Promise<string | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatCardProps {
|
||||||
|
label: string;
|
||||||
|
value: number | string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ label, value, color }: StatCardProps) {
|
||||||
|
return (
|
||||||
|
<div className={`bg-slate-800 rounded-xl border ${color} p-5`}>
|
||||||
|
<p className="text-slate-400 text-sm">{label}</p>
|
||||||
|
<p className="text-3xl font-bold text-white mt-1">{value}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Dashboard({ token, onUnauthorized }: Props) {
|
||||||
|
const [stats, setStats] = useState<DashboardStats | null>(null);
|
||||||
|
const [daily, setDaily] = useState<DailyStats[]>([]);
|
||||||
|
const [activeUsers, setActiveUsers] = useState<ActiveUser[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const [s, d, u] = await Promise.all([
|
||||||
|
getDashboard(token, onUnauthorized),
|
||||||
|
getDailyStats(token, onUnauthorized, 30),
|
||||||
|
getActiveUsers(token, onUnauthorized, 7),
|
||||||
|
]);
|
||||||
|
setStats(s);
|
||||||
|
setDaily(d);
|
||||||
|
setActiveUsers(u);
|
||||||
|
} catch {
|
||||||
|
setError("Failed to load dashboard data.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void load();
|
||||||
|
}, [token, onUnauthorized]);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="animate-spin rounded-full h-10 w-10 border-t-2 border-indigo-500" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="bg-red-900/40 border border-red-700 rounded-xl p-6 text-red-300">{error}</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Stat cards */}
|
||||||
|
{stats && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<StatCard label="Total Queries" value={stats.totalQueries} color="border-indigo-700" />
|
||||||
|
<StatCard label="Total Users" value={stats.totalUsers} color="border-teal-700" />
|
||||||
|
<StatCard label="Active Today" value={stats.activeUsersToday} color="border-emerald-700" />
|
||||||
|
<StatCard
|
||||||
|
label="Event Types"
|
||||||
|
value={Object.keys(stats.eventBreakdown).length}
|
||||||
|
color="border-violet-700"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Event breakdown */}
|
||||||
|
<div className="bg-slate-800 rounded-xl border border-slate-700 p-5">
|
||||||
|
<h2 className="text-slate-200 font-semibold mb-4">Event Breakdown</h2>
|
||||||
|
<div className="flex flex-wrap gap-3">
|
||||||
|
{Object.entries(stats.eventBreakdown).map(([type, count]) => (
|
||||||
|
<div
|
||||||
|
key={type}
|
||||||
|
className="bg-slate-700 rounded-lg px-4 py-2 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<span className="text-slate-300 text-sm">{type}</span>
|
||||||
|
<span className="text-indigo-400 font-bold">{count}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Daily queries chart */}
|
||||||
|
{daily.length > 0 && (
|
||||||
|
<div className="bg-slate-800 rounded-xl border border-slate-700 p-5">
|
||||||
|
<h2 className="text-slate-200 font-semibold mb-4">Daily Queries (last 30 days)</h2>
|
||||||
|
<ResponsiveContainer width="100%" height={220}>
|
||||||
|
<LineChart data={daily} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="date"
|
||||||
|
tick={{ fill: "#94a3b8", fontSize: 11 }}
|
||||||
|
tickFormatter={(v: string) => v.slice(5)}
|
||||||
|
/>
|
||||||
|
<YAxis tick={{ fill: "#94a3b8", fontSize: 11 }} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{ backgroundColor: "#1e293b", border: "1px solid #334155", borderRadius: 8 }}
|
||||||
|
labelStyle={{ color: "#cbd5e1" }}
|
||||||
|
itemStyle={{ color: "#818cf8" }}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="queries"
|
||||||
|
stroke="#818cf8"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
name="Queries"
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="activeUsers"
|
||||||
|
stroke="#34d399"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
name="Active Users"
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Active users table */}
|
||||||
|
{activeUsers.length > 0 && (
|
||||||
|
<div className="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
|
||||||
|
<div className="px-5 py-4 border-b border-slate-700">
|
||||||
|
<h2 className="text-slate-200 font-semibold">Active Users (last 7 days)</h2>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-slate-400 text-left">
|
||||||
|
<th className="px-5 py-3 font-medium">User ID</th>
|
||||||
|
<th className="px-5 py-3 font-medium">Queries</th>
|
||||||
|
<th className="px-5 py-3 font-medium">Chats</th>
|
||||||
|
<th className="px-5 py-3 font-medium">First Seen</th>
|
||||||
|
<th className="px-5 py-3 font-medium">Last Active</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{activeUsers.map((u) => (
|
||||||
|
<tr key={u.id} className="border-t border-slate-700 hover:bg-slate-700/40">
|
||||||
|
<td className="px-5 py-3 text-indigo-400 font-medium">{u.userId}</td>
|
||||||
|
<td className="px-5 py-3 text-slate-200">{u.totalQueries}</td>
|
||||||
|
<td className="px-5 py-3 text-slate-200">{u.totalChats}</td>
|
||||||
|
<td className="px-5 py-3 text-slate-400">{u.firstSeen.slice(0, 10)}</td>
|
||||||
|
<td className="px-5 py-3 text-slate-400">{u.lastActive.slice(0, 10)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
241
analytics-view/src/components/EventsTable.tsx
Normal file
241
analytics-view/src/components/EventsTable.tsx
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { getEvents } from "../api/analyticsApi";
|
||||||
|
import type { EventFilters, EventLog, PageResponse, SortDir } from "../types";
|
||||||
|
import { EVENT_TYPES } from "../types";
|
||||||
|
import { Pagination } from "./Pagination";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
token: string;
|
||||||
|
onUnauthorized: () => Promise<string | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
type SortableField = keyof Pick<EventLog, "id" | "eventType" | "userId" | "chatId" | "eventTimestamp" | "eventDate">;
|
||||||
|
|
||||||
|
const COLUMNS: { key: SortableField; label: string }[] = [
|
||||||
|
{ key: "id", label: "ID" },
|
||||||
|
{ key: "eventType", label: "Event Type" },
|
||||||
|
{ key: "userId", label: "User ID" },
|
||||||
|
{ key: "chatId", label: "Chat ID" },
|
||||||
|
{ key: "eventTimestamp", label: "Timestamp" },
|
||||||
|
{ key: "eventDate", label: "Date" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const EVENT_TYPE_COLORS: Record<string, string> = {
|
||||||
|
QUERY_SENT: "bg-indigo-900/50 text-indigo-300 border-indigo-700",
|
||||||
|
CHAT_CREATED: "bg-emerald-900/50 text-emerald-300 border-emerald-700",
|
||||||
|
CHAT_DELETED: "bg-red-900/50 text-red-300 border-red-700",
|
||||||
|
USER_CREATED: "bg-teal-900/50 text-teal-300 border-teal-700",
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_FILTERS: EventFilters = {
|
||||||
|
eventType: "ALL",
|
||||||
|
userId: "",
|
||||||
|
dateFrom: "",
|
||||||
|
dateTo: "",
|
||||||
|
page: 0,
|
||||||
|
size: 20,
|
||||||
|
sortField: "eventTimestamp",
|
||||||
|
sortDir: "desc",
|
||||||
|
};
|
||||||
|
|
||||||
|
function SortIcon({ active, dir }: { active: boolean; dir: SortDir }) {
|
||||||
|
if (!active) return <span className="ml-1 text-slate-600">↕</span>;
|
||||||
|
return <span className="ml-1 text-indigo-400">{dir === "asc" ? "↑" : "↓"}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EventsTable({ token, onUnauthorized }: Props) {
|
||||||
|
const [filters, setFilters] = useState<EventFilters>(DEFAULT_FILTERS);
|
||||||
|
const [data, setData] = useState<PageResponse<EventLog> | null>(null);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
|
||||||
|
const fetchEvents = useCallback(
|
||||||
|
async (f: EventFilters) => {
|
||||||
|
setLoading(true);
|
||||||
|
setError("");
|
||||||
|
try {
|
||||||
|
const res = await getEvents(token, onUnauthorized, f);
|
||||||
|
setData(res);
|
||||||
|
} catch {
|
||||||
|
setError("Failed to load events.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[token, onUnauthorized],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void fetchEvents(filters);
|
||||||
|
}, [filters, fetchEvents]);
|
||||||
|
|
||||||
|
function handleSort(field: SortableField) {
|
||||||
|
setFilters((prev) => ({
|
||||||
|
...prev,
|
||||||
|
page: 0,
|
||||||
|
sortField: field,
|
||||||
|
sortDir: prev.sortField === field && prev.sortDir === "asc" ? "desc" : "asc",
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFilterChange(patch: Partial<EventFilters>) {
|
||||||
|
setFilters((prev) => ({ ...prev, ...patch, page: 0 }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleReset() {
|
||||||
|
setFilters(DEFAULT_FILTERS);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="bg-slate-800 rounded-xl border border-slate-700 p-4">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-3">
|
||||||
|
{/* Event type */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-400 mb-1">Event Type</label>
|
||||||
|
<select
|
||||||
|
value={filters.eventType}
|
||||||
|
onChange={(e) => handleFilterChange({ eventType: e.target.value })}
|
||||||
|
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-slate-200 text-sm
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
>
|
||||||
|
{EVENT_TYPES.map((t) => (
|
||||||
|
<option key={t} value={t}>{t}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User ID */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-400 mb-1">User ID</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={filters.userId}
|
||||||
|
onChange={(e) => handleFilterChange({ userId: e.target.value })}
|
||||||
|
placeholder="Any"
|
||||||
|
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-slate-200 text-sm
|
||||||
|
placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date From */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-400 mb-1">Date From</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.dateFrom}
|
||||||
|
onChange={(e) => handleFilterChange({ dateFrom: e.target.value })}
|
||||||
|
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-slate-200 text-sm
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-indigo-500 [color-scheme:dark]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Date To */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-slate-400 mb-1">Date To</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
value={filters.dateTo}
|
||||||
|
onChange={(e) => handleFilterChange({ dateTo: e.target.value })}
|
||||||
|
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-slate-200 text-sm
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-indigo-500 [color-scheme:dark]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between mt-3">
|
||||||
|
<button
|
||||||
|
onClick={handleReset}
|
||||||
|
className="text-sm text-slate-400 hover:text-slate-200 transition-colors"
|
||||||
|
>
|
||||||
|
Reset filters
|
||||||
|
</button>
|
||||||
|
{data && (
|
||||||
|
<span className="text-xs text-slate-500">
|
||||||
|
{data.totalElements} events found
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Table */}
|
||||||
|
<div className="bg-slate-800 rounded-xl border border-slate-700 overflow-hidden">
|
||||||
|
{loading && (
|
||||||
|
<div className="flex items-center justify-center h-32">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-indigo-500" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && error && (
|
||||||
|
<div className="p-6 text-red-300 bg-red-900/30">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && data && (
|
||||||
|
<>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="text-slate-400 text-left border-b border-slate-700">
|
||||||
|
{COLUMNS.map((col) => (
|
||||||
|
<th
|
||||||
|
key={col.key}
|
||||||
|
className="px-4 py-3 font-medium cursor-pointer hover:text-slate-200 select-none whitespace-nowrap"
|
||||||
|
onClick={() => handleSort(col.key)}
|
||||||
|
>
|
||||||
|
{col.label}
|
||||||
|
<SortIcon
|
||||||
|
active={filters.sortField === col.key}
|
||||||
|
dir={filters.sortDir}
|
||||||
|
/>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.content.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="px-4 py-10 text-center text-slate-500">
|
||||||
|
No events found
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
data.content.map((event) => (
|
||||||
|
<tr key={event.id} className="border-t border-slate-700/60 hover:bg-slate-700/30">
|
||||||
|
<td className="px-4 py-3 text-slate-400">{event.id}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span
|
||||||
|
className={`inline-block px-2 py-0.5 rounded border text-xs font-medium
|
||||||
|
${EVENT_TYPE_COLORS[event.eventType] ?? "bg-slate-700 text-slate-300 border-slate-600"}`}
|
||||||
|
>
|
||||||
|
{event.eventType}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-slate-200">{event.userId}</td>
|
||||||
|
<td className="px-4 py-3 text-slate-400">{event.chatId ?? "—"}</td>
|
||||||
|
<td className="px-4 py-3 text-slate-300 whitespace-nowrap font-mono text-xs">
|
||||||
|
{event.eventTimestamp.replace("T", " ").slice(0, 19)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-slate-400">{event.eventDate}</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 py-3 border-t border-slate-700">
|
||||||
|
<Pagination
|
||||||
|
page={data.number}
|
||||||
|
totalPages={data.totalPages}
|
||||||
|
totalElements={data.totalElements}
|
||||||
|
pageSize={data.size}
|
||||||
|
onPageChange={(p) => setFilters((prev) => ({ ...prev, page: p }))}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
analytics-view/src/components/LoginForm.tsx
Normal file
94
analytics-view/src/components/LoginForm.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { FormEvent, useState } from "react";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
onLogin: (email: string, password: string) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LoginForm({ onLogin }: Props) {
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
async function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
setError("");
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await onLogin(email, password);
|
||||||
|
} catch {
|
||||||
|
setError("Invalid credentials. Please try again.");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-slate-900">
|
||||||
|
<div className="w-full max-w-md">
|
||||||
|
<div className="bg-slate-800 rounded-2xl shadow-2xl border border-slate-700 p-8">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="text-center mb-8">
|
||||||
|
<div className="inline-flex items-center justify-center w-14 h-14 rounded-xl bg-indigo-600 mb-4">
|
||||||
|
<svg className="w-8 h-8 text-white" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2}
|
||||||
|
d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-bold text-white">Analytics Viewer</h1>
|
||||||
|
<p className="text-slate-400 text-sm mt-1">Sign in to access the dashboard</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-300 mb-1.5">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="email"
|
||||||
|
placeholder="you@example.com"
|
||||||
|
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-4 py-2.5 text-white placeholder-slate-500
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-slate-300 mb-1.5">
|
||||||
|
Password
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
placeholder="••••••••"
|
||||||
|
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-4 py-2.5 text-white placeholder-slate-500
|
||||||
|
focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-900/40 border border-red-700 rounded-lg px-4 py-3 text-red-300 text-sm">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-indigo-600 hover:bg-indigo-500 disabled:opacity-60 disabled:cursor-not-allowed
|
||||||
|
text-white font-semibold rounded-lg px-4 py-2.5 transition-colors"
|
||||||
|
>
|
||||||
|
{loading ? "Signing in…" : "Sign In"}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
analytics-view/src/components/Navbar.tsx
Normal file
53
analytics-view/src/components/Navbar.tsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { Link, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
username: string;
|
||||||
|
onLogout: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Navbar({ username, onLogout }: Props) {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
|
function navClass(path: string) {
|
||||||
|
const base =
|
||||||
|
"px-4 py-2 rounded-lg text-sm font-medium transition-colors";
|
||||||
|
return pathname === path
|
||||||
|
? `${base} bg-indigo-600 text-white`
|
||||||
|
: `${base} text-slate-300 hover:text-white hover:bg-slate-700`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="bg-slate-800 border-b border-slate-700 sticky top-0 z-10">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="flex items-center justify-between h-14">
|
||||||
|
{/* Logo + nav links */}
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-indigo-400 font-bold text-lg mr-4 hidden sm:block">
|
||||||
|
Analytics
|
||||||
|
</span>
|
||||||
|
<Link to="/" className={navClass("/")}>
|
||||||
|
Dashboard
|
||||||
|
</Link>
|
||||||
|
<Link to="/events" className={navClass("/events")}>
|
||||||
|
Events
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User info + logout */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-slate-400 text-sm hidden sm:block">
|
||||||
|
{username}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={onLogout}
|
||||||
|
className="text-sm font-medium text-slate-300 hover:text-white bg-slate-700 hover:bg-slate-600
|
||||||
|
px-3 py-1.5 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
analytics-view/src/components/Pagination.tsx
Normal file
73
analytics-view/src/components/Pagination.tsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
interface Props {
|
||||||
|
page: number;
|
||||||
|
totalPages: number;
|
||||||
|
totalElements: number;
|
||||||
|
pageSize: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Pagination({ page, totalPages, totalElements, pageSize, onPageChange }: Props) {
|
||||||
|
if (totalPages <= 1) return null;
|
||||||
|
|
||||||
|
const from = page * pageSize + 1;
|
||||||
|
const to = Math.min((page + 1) * pageSize, totalElements);
|
||||||
|
|
||||||
|
// Build visible page numbers (window of 5 around current)
|
||||||
|
const pages: (number | "…")[] = [];
|
||||||
|
if (totalPages <= 7) {
|
||||||
|
for (let i = 0; i < totalPages; i++) pages.push(i);
|
||||||
|
} else {
|
||||||
|
pages.push(0);
|
||||||
|
if (page > 2) pages.push("…");
|
||||||
|
for (let i = Math.max(1, page - 1); i <= Math.min(totalPages - 2, page + 1); i++) {
|
||||||
|
pages.push(i);
|
||||||
|
}
|
||||||
|
if (page < totalPages - 3) pages.push("…");
|
||||||
|
pages.push(totalPages - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function btnClass(active: boolean, disabled: boolean) {
|
||||||
|
const base = "px-3 py-1.5 rounded-lg text-sm font-medium transition-colors";
|
||||||
|
if (disabled) return `${base} opacity-40 cursor-not-allowed text-slate-500`;
|
||||||
|
if (active) return `${base} bg-indigo-600 text-white`;
|
||||||
|
return `${base} text-slate-300 hover:bg-slate-700 hover:text-white`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col sm:flex-row items-center justify-between gap-3 mt-4">
|
||||||
|
<p className="text-sm text-slate-400">
|
||||||
|
Showing <span className="text-slate-200">{from}–{to}</span> of{" "}
|
||||||
|
<span className="text-slate-200">{totalElements}</span> events
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
disabled={page === 0}
|
||||||
|
onClick={() => onPageChange(page - 1)}
|
||||||
|
className={btnClass(false, page === 0)}
|
||||||
|
>
|
||||||
|
← Prev
|
||||||
|
</button>
|
||||||
|
{pages.map((p, i) =>
|
||||||
|
p === "…" ? (
|
||||||
|
<span key={`ellipsis-${i}`} className="px-2 text-slate-500">…</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => onPageChange(p)}
|
||||||
|
className={btnClass(p === page, false)}
|
||||||
|
>
|
||||||
|
{p + 1}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
disabled={page === totalPages - 1}
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
className={btnClass(false, page === totalPages - 1)}
|
||||||
|
>
|
||||||
|
Next →
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
analytics-view/src/hooks/useAuth.ts
Normal file
35
analytics-view/src/hooks/useAuth.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useCallback, useState } from "react";
|
||||||
|
import { login as apiLogin, refreshToken as apiRefresh } from "../api/authApi";
|
||||||
|
import type { AuthPayload } from "../types";
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const [auth, setAuth] = useState<AuthPayload | null>(null);
|
||||||
|
|
||||||
|
const login = useCallback(async (email: string, password: string) => {
|
||||||
|
const res = await apiLogin(email, password);
|
||||||
|
setAuth(res.payload);
|
||||||
|
return res.payload;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
setAuth(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Returns new access token or null if refresh fails → triggers logout
|
||||||
|
const refresh = useCallback(async (): Promise<string | null> => {
|
||||||
|
if (!auth?.refreshToken) {
|
||||||
|
setAuth(null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await apiRefresh(auth.refreshToken);
|
||||||
|
setAuth(res.payload);
|
||||||
|
return res.payload.token;
|
||||||
|
} catch {
|
||||||
|
setAuth(null);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [auth]);
|
||||||
|
|
||||||
|
return { auth, login, logout, refresh };
|
||||||
|
}
|
||||||
17
analytics-view/src/index.css
Normal file
17
analytics-view/src/index.css
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
background-color: #0f172a;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
13
analytics-view/src/main.tsx
Normal file
13
analytics-view/src/main.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { StrictMode } from "react";
|
||||||
|
import { createRoot } from "react-dom/client";
|
||||||
|
import { BrowserRouter } from "react-router-dom";
|
||||||
|
import "./index.css";
|
||||||
|
import App from "./App";
|
||||||
|
|
||||||
|
createRoot(document.getElementById("root")!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<BrowserRouter basename="/analyticsview">
|
||||||
|
<App />
|
||||||
|
</BrowserRouter>
|
||||||
|
</StrictMode>,
|
||||||
|
);
|
||||||
79
analytics-view/src/types/index.ts
Normal file
79
analytics-view/src/types/index.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
export interface AuthPayload {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
email: string;
|
||||||
|
registrationStatus: string;
|
||||||
|
lastLogin: string;
|
||||||
|
token: string;
|
||||||
|
refreshToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AuthResponse {
|
||||||
|
message: string;
|
||||||
|
payload: AuthPayload;
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
totalQueries: number;
|
||||||
|
totalUsers: number;
|
||||||
|
activeUsersToday: number;
|
||||||
|
eventBreakdown: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActiveUser {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
totalQueries: number;
|
||||||
|
totalChats: number;
|
||||||
|
firstSeen: string;
|
||||||
|
lastActive: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DailyStats {
|
||||||
|
date: string;
|
||||||
|
queries: number;
|
||||||
|
newUsers: number;
|
||||||
|
newChats: number;
|
||||||
|
activeUsers: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventLog {
|
||||||
|
id: number;
|
||||||
|
eventType: string;
|
||||||
|
userId: string;
|
||||||
|
chatId: string | null;
|
||||||
|
eventTimestamp: string;
|
||||||
|
eventDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PageResponse<T> {
|
||||||
|
content: T[];
|
||||||
|
totalElements: number;
|
||||||
|
totalPages: number;
|
||||||
|
number: number;
|
||||||
|
size: number;
|
||||||
|
first: boolean;
|
||||||
|
last: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SortDir = "asc" | "desc";
|
||||||
|
|
||||||
|
export interface EventFilters {
|
||||||
|
eventType: string;
|
||||||
|
userId: string;
|
||||||
|
dateFrom: string;
|
||||||
|
dateTo: string;
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
sortField: string;
|
||||||
|
sortDir: SortDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EVENT_TYPES = [
|
||||||
|
"ALL",
|
||||||
|
"USER_CREATED",
|
||||||
|
"CHAT_CREATED",
|
||||||
|
"CHAT_DELETED",
|
||||||
|
"QUERY_SENT",
|
||||||
|
] as const;
|
||||||
1
analytics-view/src/vite-env.d.ts
vendored
Normal file
1
analytics-view/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
22
analytics-view/tsconfig.app.json
Normal file
22
analytics-view/tsconfig.app.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
analytics-view/tsconfig.json
Normal file
7
analytics-view/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
20
analytics-view/tsconfig.node.json
Normal file
20
analytics-view/tsconfig.node.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
7
analytics-view/vite.config.ts
Normal file
7
analytics-view/vite.config.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
base: "/analyticsview/",
|
||||||
|
});
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "rag-topic-viewer",
|
|
||||||
"private": true,
|
|
||||||
"version": "0.0.0",
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"dev": "vite",
|
|
||||||
"build": "tsc -b && vite build",
|
|
||||||
"lint": "eslint .",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@tanstack/react-query": "^5.80.7",
|
|
||||||
"react": "^19.1.1",
|
|
||||||
"react-dom": "^19.1.1",
|
|
||||||
"react-router-dom": "^7.0.2"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@eslint/js": "^9.36.0",
|
|
||||||
"@tailwindcss/postcss": "^4.1.18",
|
|
||||||
"@types/react": "^19.1.13",
|
|
||||||
"@types/react-dom": "^19.1.9",
|
|
||||||
"@vitejs/plugin-react": "^5.0.3",
|
|
||||||
"autoprefixer": "^10.4.23",
|
|
||||||
"eslint": "^9.36.0",
|
|
||||||
"eslint-plugin-react-hooks": "^5.2.0",
|
|
||||||
"eslint-plugin-react-refresh": "^0.4.20",
|
|
||||||
"globals": "^16.4.0",
|
|
||||||
"postcss": "^8.5.6",
|
|
||||||
"tailwindcss": "^4.1.18",
|
|
||||||
"typescript": "^5.8.3",
|
|
||||||
"typescript-eslint": "^8.35.1",
|
|
||||||
"vite": "^7.1.7"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user