diff --git a/tradein-mvp/frontend/src/app/admin/analytics/page.tsx b/tradein-mvp/frontend/src/app/admin/analytics/page.tsx
new file mode 100644
index 00000000..66f2550e
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/admin/analytics/page.tsx
@@ -0,0 +1,252 @@
+"use client";
+
+/**
+ * Активность (Feature 3) — поведенческая аналитика по user_events: сколько
+ * событий, кто активен, что чаще ищут/дёргают. Названо «Активность», не
+ * «Аналитика» — этот label уже занят market-analytics overlay (АНАЛИТИКА ДОМА,
+ * v2/AnalyticsView.tsx) внутри самой оценки, здесь другой домен (юзер-активность).
+ *
+ * Admin-only — тот же паттерн gating, что и /admin/audit (см. комментарий там).
+ */
+
+import { useMemo, useState } from "react";
+import {
+ CartesianGrid,
+ Line,
+ LineChart,
+ ResponsiveContainer,
+ Tooltip,
+ XAxis,
+ YAxis,
+} from "recharts";
+
+import "@/components/trade-in/trade-in.css";
+import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
+import { Topbar } from "@/components/trade-in/Topbar";
+import { HTTPError } from "@/lib/api";
+import { useUserAnalytics } from "@/lib/admin-audit-api";
+
+const DAYS_OPTIONS = [7, 30, 90] as const;
+type DaysOption = (typeof DAYS_OPTIONS)[number];
+
+function formatDay(iso: string): string {
+ try {
+ return new Date(iso).toLocaleDateString("ru-RU", { day: "2-digit", month: "2-digit" });
+ } catch {
+ return iso;
+ }
+}
+
+function formatDateTime(iso: string): string {
+ try {
+ return new Date(iso).toLocaleString("ru-RU", {
+ day: "2-digit",
+ month: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ } catch {
+ return iso;
+ }
+}
+
+function KpiCard({ label, value }: { label: string; value: number }) {
+ return (
+
+
{label}
+
{value.toLocaleString("ru-RU")}
+
+ );
+}
+
+interface DailyChartPoint {
+ day: string;
+ events: number;
+ users: number;
+ label: string;
+}
+
+export default function AdminAnalyticsPage() {
+ const [days, setDays] = useState(30);
+ const analyticsQ = useUserAnalytics(days);
+
+ const chartData = useMemo(
+ () => (analyticsQ.data?.daily ?? []).map((p) => ({ ...p, label: formatDay(p.day) })),
+ [analyticsQ.data],
+ );
+
+ if (analyticsQ.error instanceof HTTPError && analyticsQ.error.status === 403) {
+ return ;
+ }
+
+ return (
+ <>
+
+
+ Активность
+
+ Поведенческая аналитика по всем аккаунтам: события, активные
+ пользователи, частые поиски и API-пути.
+
+
+
+ {DAYS_OPTIONS.map((d) => (
+
+ ))}
+
+
+ {analyticsQ.isPending && Загрузка…
}
+ {analyticsQ.isError && (
+
+ Ошибка загрузки: {analyticsQ.error.message}
+
+ )}
+
+ {analyticsQ.data && (
+ <>
+
+
+
+
+
+
+
+
+ Динамика по дням
+ События и уникальные пользователи в день.
+ {chartData.length < 2 ? (
+
+ Недостаточно данных для графика — нужно минимум 2 дня наблюдений.
+
+ ) : (
+
+
+
+
+
+
+ label} />
+
+
+
+
+
+ )}
+
+
+
+
+ Топ поисковых адресов
+ {analyticsQ.data.top_searches.length === 0 ? (
+
+ Пока нет данных.
+
+ ) : (
+
+
+
+ | Адрес |
+ Поисков |
+
+
+
+ {analyticsQ.data.top_searches.map((row, i) => (
+
+ | {row.address ?? "—"} |
+ {row.n} |
+
+ ))}
+
+
+ )}
+
+
+
+ Топ API-путей
+ {analyticsQ.data.top_paths.length === 0 ? (
+
+ Пока нет данных.
+
+ ) : (
+
+
+
+ | Путь |
+ Запросов |
+
+
+
+ {analyticsQ.data.top_paths.map((row, i) => (
+
+ | {row.path ?? "—"} |
+ {row.n} |
+
+ ))}
+
+
+ )}
+
+
+
+
+ Активность по аккаунтам
+ {analyticsQ.data.by_account.length === 0 ? (
+
+ Пока нет данных.
+
+ ) : (
+
+
+
+ | Аккаунт |
+ События |
+ Поиски |
+ Последняя активность |
+
+
+
+ {analyticsQ.data.by_account.map((row) => (
+
+ | {row.username} |
+ {row.events} |
+ {row.searches} |
+ {formatDateTime(row.last_seen)} |
+
+ ))}
+
+
+ )}
+
+ >
+ )}
+
+ >
+ );
+}
diff --git a/tradein-mvp/frontend/src/app/admin/audit/page.tsx b/tradein-mvp/frontend/src/app/admin/audit/page.tsx
new file mode 100644
index 00000000..b30212f1
--- /dev/null
+++ b/tradein-mvp/frontend/src/app/admin/audit/page.tsx
@@ -0,0 +1,248 @@
+"use client";
+
+/**
+ * Аудит доступа (Feature 2) — с каких IP/устройств заходят пилотные аккаунты и
+ * что они ищут. Admin-only: nav entry в Topbar скрыт для не-admin ролей (see
+ * NAV_ITEMS scopePath), backend 403-ит non-admin регардлесс — обрабатываем
+ * это здесь, а не крашимся (RouteGuard сам по себе НЕ блокирует прямой заход
+ * на /admin/audit для pilot, см. комментарий в Topbar.tsx про /scrapers —
+ * тот же паттерн).
+ */
+
+import { Fragment, useState, type ReactNode } from "react";
+
+import "@/components/trade-in/trade-in.css";
+import { NoAccessScreen } from "@/components/auth/NoAccessScreen";
+import { Topbar } from "@/components/trade-in/Topbar";
+import { HTTPError } from "@/lib/api";
+import {
+ useAccountDrilldown,
+ useAuditAccounts,
+ type AccountDrilldown,
+} from "@/lib/admin-audit-api";
+
+function formatDateTime(iso: string | null): string {
+ if (!iso) return "—";
+ try {
+ return new Date(iso).toLocaleString("ru-RU", {
+ day: "2-digit",
+ month: "2-digit",
+ year: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+ } catch {
+ return iso;
+ }
+}
+
+function DrilldownSection({
+ title,
+ rows,
+ render,
+}: {
+ title: string;
+ rows: T[];
+ render: (row: T, i: number) => ReactNode;
+}) {
+ return (
+
+
+ {title} ({rows.length})
+
+ {rows.length === 0 ? (
+
+ Нет данных.
+
+ ) : (
+
+ )}
+
+ );
+}
+
+function AccountDrilldownPanel({ username }: { username: string }) {
+ const drilldownQ = useAccountDrilldown(username);
+
+ if (drilldownQ.isError) {
+ return (
+
+ Не удалось загрузить детали аккаунта: {drilldownQ.error.message}
+
+ );
+ }
+
+ const d: AccountDrilldown | undefined = drilldownQ.data;
+ if (!d) {
+ return (
+
+ Загрузка деталей…
+
+ );
+ }
+
+ const hasAnyData =
+ d.ips.length > 0 ||
+ d.devices.length > 0 ||
+ d.searches.length > 0 ||
+ d.recent_activity.length > 0;
+
+ if (!hasAnyData) {
+ return (
+
+ Пока нет данных по этому аккаунту.
+
+ );
+ }
+
+ return (
+
+ (
+
+ | {row.ip_address ?? "—"} |
+ {row.event_count} событ. |
+
+ {formatDateTime(row.first_seen)} → {formatDateTime(row.last_seen)}
+ |
+
+ )}
+ />
+ (
+
+ |
+ {row.user_agent ?? "—"}
+ |
+ {row.event_count} событ. |
+ {formatDateTime(row.last_seen)} |
+
+ )}
+ />
+ (
+
+ | {row.address ?? "—"} |
+
+ {row.rooms ?? "?"}к · {row.area_m2 ?? "?"} м²
+ |
+
+ {formatDateTime(row.created_at)} · {row.ip_address ?? "—"}
+ |
+
+ )}
+ />
+ (
+
+ | {row.event_type} |
+
+ {row.method ?? ""} {row.path ?? "—"}
+ |
+ {formatDateTime(row.created_at)} |
+
+ )}
+ />
+
+ );
+}
+
+export default function AdminAuditPage() {
+ const accountsQ = useAuditAccounts();
+ const [expandedUser, setExpandedUser] = useState(null);
+
+ // Backend 403-ит non-admin регардлесс (nav уже прячет ссылку) — прямой заход
+ // на URL не должен крашить страницу, показываем тот же экран, что RouteGuard
+ // рендерит для path-deny.
+ if (accountsQ.error instanceof HTTPError && accountsQ.error.status === 403) {
+ return ;
+ }
+
+ return (
+ <>
+
+
+ Аудит доступа
+
+ С каких IP-адресов и устройств заходят аккаунты и что они ищут. Клик по
+ аккаунту раскрывает детали.
+
+
+
+ {accountsQ.isPending && Загрузка…
}
+ {accountsQ.isError && (
+
+ Ошибка загрузки: {accountsQ.error.message}
+
+ )}
+ {accountsQ.data && accountsQ.data.length === 0 && (
+
+ Пока нет данных — событий аудита ещё не собрано.
+
+ )}
+ {accountsQ.data && accountsQ.data.length > 0 && (
+
+
+
+ | Аккаунт |
+ Последний визит |
+ Первый визит |
+ IP |
+ Устройства |
+ Входы |
+ Запросы |
+ Поиски |
+
+
+
+ {accountsQ.data.map((a) => {
+ const isOpen = expandedUser === a.username;
+ return (
+
+
+ |
+
+ |
+ {formatDateTime(a.last_seen_at)} |
+ {formatDateTime(a.first_seen_at)} |
+ {a.distinct_ips} |
+ {a.distinct_devices} |
+ {a.login_count} |
+ {a.request_count} |
+ {a.search_count} |
+
+ {isOpen && (
+
+ |
+
+ |
+
+ )}
+
+ );
+ })}
+
+
+ )}
+
+
+ >
+ );
+}
diff --git a/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx b/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx
index d1e9628d..094dda63 100644
--- a/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx
+++ b/tradein-mvp/frontend/src/components/trade-in/Topbar.tsx
@@ -82,7 +82,9 @@ export type ActiveTab =
| "scrapers"
| "avito"
| "cian"
- | "yandex";
+ | "yandex"
+ | "audit"
+ | "analytics";
interface TopbarProps {
active: ActiveTab;
@@ -145,6 +147,21 @@ const NAV_ITEMS: Array<{
scopePath: "/trade-in/api/v1/admin/scrapers/yandex",
label: "Яндекс",
},
+ // Аудит доступа (Feature 2) + Активность (Feature 3) — admin-only read API
+ // над user_events (см. app/api/v1/audit.py). Тот же admin-scopePath паттерн,
+ // что и скрапперы выше: pilot.deny покрывает /trade-in/api/v1/admin/**.
+ {
+ key: "audit",
+ href: "/admin/audit",
+ scopePath: "/trade-in/api/v1/admin/audit",
+ label: "Аудит",
+ },
+ {
+ key: "analytics",
+ href: "/admin/analytics",
+ scopePath: "/trade-in/api/v1/admin/analytics",
+ label: "Активность",
+ },
];
export function Topbar({ active }: TopbarProps) {
diff --git a/tradein-mvp/frontend/src/components/trade-in/trade-in.css b/tradein-mvp/frontend/src/components/trade-in/trade-in.css
index d2040092..6a7bcbfe 100644
--- a/tradein-mvp/frontend/src/components/trade-in/trade-in.css
+++ b/tradein-mvp/frontend/src/components/trade-in/trade-in.css
@@ -2732,3 +2732,91 @@ html, body { overflow-x: clip; }
.ss-hist { height: 76px; }
.ss-map, .ss-map-empty { height: 360px; }
}
+
+/* ── Admin dashboards: audit (Feature 2) + activity (Feature 3), #2521 ── */
+
+.admin-kpi-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 12px;
+ margin-bottom: 20px;
+}
+
+.admin-kpi-card {
+ background: var(--bg-card, #ffffff);
+ border: 1px solid var(--border-card, #e6e8ec);
+ border-radius: 10px;
+ padding: 14px 16px;
+}
+
+.admin-kpi-label {
+ font-size: 11px;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+ color: var(--fg-secondary, #5b6066);
+}
+
+.admin-kpi-value {
+ font-size: 26px;
+ font-weight: 600;
+ color: var(--fg-primary, #111111);
+ margin-top: 6px;
+ font-variant-numeric: tabular-nums;
+}
+
+.admin-days-filter {
+ display: flex;
+ gap: 8px;
+ margin-bottom: 16px;
+ flex-wrap: wrap;
+}
+
+.admin-days-filter button {
+ padding: 4px 12px;
+ border-radius: 6px;
+ border: 1px solid var(--border-card, #e6e8ec);
+ font-size: 13px;
+ cursor: pointer;
+ background: var(--bg-card, #fff);
+ color: var(--fg-secondary, #5b6066);
+}
+
+.admin-days-filter button.is-active {
+ background: var(--accent, #1d4ed8);
+ border-color: var(--accent, #1d4ed8);
+ color: #fff;
+}
+
+.admin-row-toggle {
+ background: none;
+ border: none;
+ padding: 0;
+ font: inherit;
+ color: var(--accent, #1d4ed8);
+ cursor: pointer;
+ text-align: left;
+}
+
+.admin-row-toggle:hover {
+ text-decoration: underline;
+}
+
+.admin-drilldown {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
+ gap: 16px;
+ padding: 14px 6px;
+}
+
+.admin-drilldown h4 {
+ font-size: 12px;
+ font-weight: 600;
+ color: var(--fg-primary, #111111);
+ margin: 0 0 8px;
+}
+
+.admin-two-col {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(360px, 1fr));
+ gap: 16px;
+}
diff --git a/tradein-mvp/frontend/src/lib/admin-audit-api.ts b/tradein-mvp/frontend/src/lib/admin-audit-api.ts
new file mode 100644
index 00000000..d7444a9e
--- /dev/null
+++ b/tradein-mvp/frontend/src/lib/admin-audit-api.ts
@@ -0,0 +1,155 @@
+"use client";
+
+/**
+ * Admin-only read API over `user_events` — Feature 2 (login/IP audit) + Feature 3
+ * (behavior analytics dashboard). Mirrors `app/api/v1/audit.py` +
+ * `app/schemas/audit.py` (tradein-mvp backend, PR #2520). Hand-written types —
+ * tradein-mvp/frontend has no `codegen` script (no openapi-typescript wired up,
+ * see package.json scripts: dev/build/start/lint/type-check only).
+ *
+ * All 3 endpoints live under the admin API prefix, gated by the central
+ * `rbac_guard` middleware in `app/main.py` (role != admin → 403 regardless of
+ * frontend). `retry: false` — a 403 must not re-hammer the backend.
+ */
+
+import { useQuery } from "@tanstack/react-query";
+
+import { apiFetch } from "./api";
+
+const BASE = "/api/v1/admin";
+
+// ---- Types (mirror app/schemas/audit.py) ----------------------------------
+
+export interface AccountSummary {
+ username: string;
+ first_seen_at: string;
+ last_seen_at: string;
+ distinct_ips: number;
+ distinct_devices: number;
+ login_count: number;
+ request_count: number;
+ search_count: number;
+}
+
+export interface AccountIpEntry {
+ ip_address: string | null;
+ event_count: number;
+ first_seen: string;
+ last_seen: string;
+}
+
+export interface AccountDeviceEntry {
+ user_agent: string | null;
+ event_count: number;
+ first_seen: string;
+ last_seen: string;
+}
+
+export interface AccountSearchEntry {
+ address: string | null;
+ area_m2: string | null;
+ rooms: string | null;
+ estimate_id: string | null;
+ ip_address: string | null;
+ created_at: string;
+}
+
+export interface AccountActivityEntry {
+ event_type: string;
+ path: string | null;
+ method: string | null;
+ ip_address: string | null;
+ created_at: string;
+}
+
+export interface AccountDrilldown {
+ ips: AccountIpEntry[];
+ devices: AccountDeviceEntry[];
+ searches: AccountSearchEntry[];
+ recent_activity: AccountActivityEntry[];
+}
+
+export interface AnalyticsSummary {
+ total_events: number;
+ distinct_users: number;
+ events_last_24h: number;
+ active_users_last_24h: number;
+}
+
+export interface AnalyticsDailyPoint {
+ day: string;
+ events: number;
+ users: number;
+}
+
+export interface AnalyticsTopSearch {
+ address: string | null;
+ n: number;
+}
+
+export interface AnalyticsTopPath {
+ path: string | null;
+ n: number;
+}
+
+export interface AnalyticsByAccount {
+ username: string;
+ events: number;
+ searches: number;
+ last_seen: string;
+}
+
+export interface AnalyticsDashboard {
+ summary: AnalyticsSummary;
+ daily: AnalyticsDailyPoint[];
+ top_searches: AnalyticsTopSearch[];
+ top_paths: AnalyticsTopPath[];
+ by_account: AnalyticsByAccount[];
+}
+
+// ---- Hooks ------------------------------------------------------------------
+
+/**
+ * GET /api/v1/admin/audit/accounts
+ * Список аккаунтов, по одной строке на username — сводка активности (Feature 2).
+ */
+export function useAuditAccounts() {
+ return useQuery({
+ queryKey: ["admin", "audit", "accounts"],
+ queryFn: () => apiFetch(`${BASE}/audit/accounts`),
+ staleTime: 30_000,
+ retry: false,
+ });
+}
+
+/**
+ * GET /api/v1/admin/audit/accounts/{username}
+ * Drilldown по одному аккаунту: IP, устройства, поиски, недавняя активность.
+ * `enabled: false` пока username === null — используется для ленивого раскрытия
+ * строки в таблице аккаунтов.
+ */
+export function useAccountDrilldown(username: string | null) {
+ return useQuery({
+ queryKey: ["admin", "audit", "accounts", username],
+ queryFn: () =>
+ apiFetch(
+ `${BASE}/audit/accounts/${encodeURIComponent(username as string)}`,
+ ),
+ enabled: username !== null && username.length > 0,
+ staleTime: 30_000,
+ retry: false,
+ });
+}
+
+/**
+ * GET /api/v1/admin/analytics?days=N
+ * Feature 3 dashboard bundle: сводка, дневной time-series, топ-поиски/пути/аккаунты.
+ */
+export function useUserAnalytics(days: number) {
+ return useQuery({
+ queryKey: ["admin", "analytics", days],
+ queryFn: () => apiFetch(`${BASE}/analytics?days=${days}`),
+ staleTime: 30_000,
+ retry: false,
+ });
+}