From d0e29ce914bfb359d29e927a5d06cc7b53922ac8 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Mon, 13 Jul 2026 23:42:06 +0300 Subject: [PATCH] feat(tradein/admin): audit + activity dashboards (Feature 2/3 UI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin-only read dashboards over the merged user_events audit API (#2520): - /admin/audit — accounts table (IP/device/login/search counts) with a click-to-expand drilldown per account (IPs, devices, recent searches, recent raw activity). - /admin/analytics — "Активность" (user-behavior analytics, distinct from the market-analytics overlay label): summary KPI cards, a 7/30/90-day events/users line chart (recharts), top-searches/top-paths/by-account tables. Both pages are admin-only via the existing Topbar NAV_ITEMS admin-scopePath convention (mirrors /scrapers) so non-admin roles never see the nav entries; a direct-URL 403 from the API is caught and rendered as NoAccessScreen instead of crashing, matching the RouteGuard "user" variant. --- .../frontend/src/app/admin/analytics/page.tsx | 252 ++++++++++++++++++ .../frontend/src/app/admin/audit/page.tsx | 248 +++++++++++++++++ .../src/components/trade-in/Topbar.tsx | 19 +- .../src/components/trade-in/trade-in.css | 88 ++++++ .../frontend/src/lib/admin-audit-api.ts | 155 +++++++++++ 5 files changed, 761 insertions(+), 1 deletion(-) create mode 100644 tradein-mvp/frontend/src/app/admin/analytics/page.tsx create mode 100644 tradein-mvp/frontend/src/app/admin/audit/page.tsx create mode 100644 tradein-mvp/frontend/src/lib/admin-audit-api.ts 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 дня наблюдений. +

+ ) : ( + + )} +
+ +
+
+

Топ поисковых адресов

+ {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 ? ( +

+ Нет данных. +

+ ) : ( + + {rows.map(render)} +
+ )} +
+ ); +} + +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 && ( + + + + + + + + + + + + + + + {accountsQ.data.map((a) => { + const isOpen = expandedUser === a.username; + return ( + + + + + + + + + + + + {isOpen && ( + + + + )} + + ); + })} + +
АккаунтПоследний визитПервый визитIPУстройстваВходыЗапросыПоиски
+ + {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}
+ +
+ )} +
+
+ + ); +} 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, + }); +} -- 2.45.3