add analytics-view

This commit is contained in:
2026-03-13 23:14:51 +01:00
parent c5a3f5607d
commit 895448945a
27 changed files with 5545 additions and 35 deletions

View 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
View 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?

View 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

View 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
View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};

View 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>
);
}

View 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,
);
}

View 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>;
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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 };
}

View 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;
}

View 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>,
);

View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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"]
}

View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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"]
}

View File

@@ -0,0 +1,7 @@
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
base: "/analyticsview/",
});