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