feat(tradein/admin): audit + activity dashboards (Feature 2/3 UI) (#2521)
All checks were successful
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m56s
Deploy Trade-In / deploy (push) Successful in 46s

Admin-only /admin/audit (accounts × IP/device × searches) and /admin/analytics (KPIs, daily chart, top searches/paths/accounts) over the user_events read API. Gated like /scrapers; 403 → NoAccessScreen.
This commit is contained in:
lekss361 2026-07-13 20:45:34 +00:00
parent 9fd6396fbc
commit c904fbf94e
5 changed files with 761 additions and 1 deletions

View file

@ -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 (
<div className="admin-kpi-card">
<div className="admin-kpi-label">{label}</div>
<div className="admin-kpi-value">{value.toLocaleString("ru-RU")}</div>
</div>
);
}
interface DailyChartPoint {
day: string;
events: number;
users: number;
label: string;
}
export default function AdminAnalyticsPage() {
const [days, setDays] = useState<DaysOption>(30);
const analyticsQ = useUserAnalytics(days);
const chartData = useMemo<DailyChartPoint[]>(
() => (analyticsQ.data?.daily ?? []).map((p) => ({ ...p, label: formatDay(p.day) })),
[analyticsQ.data],
);
if (analyticsQ.error instanceof HTTPError && analyticsQ.error.status === 403) {
return <NoAccessScreen variant="user" />;
}
return (
<>
<Topbar active="analytics" />
<main className="page scraper-page" style={{ maxWidth: 1180 }}>
<h1 className="scraper-h1">Активность</h1>
<p className="scraper-subtitle">
Поведенческая аналитика по всем аккаунтам: события, активные
пользователи, частые поиски и API-пути.
</p>
<div className="admin-days-filter">
{DAYS_OPTIONS.map((d) => (
<button
key={d}
type="button"
className={days === d ? "is-active" : undefined}
onClick={() => setDays(d)}
>
{d} дн.
</button>
))}
</div>
{analyticsQ.isPending && <p className="scraper-hint">Загрузка</p>}
{analyticsQ.isError && (
<p className="scraper-result scraper-result--error">
Ошибка загрузки: {analyticsQ.error.message}
</p>
)}
{analyticsQ.data && (
<>
<div className="admin-kpi-grid">
<KpiCard label="Всего событий" value={analyticsQ.data.summary.total_events} />
<KpiCard
label="Уникальных пользователей"
value={analyticsQ.data.summary.distinct_users}
/>
<KpiCard label="События за 24ч" value={analyticsQ.data.summary.events_last_24h} />
<KpiCard
label="Активны за 24ч"
value={analyticsQ.data.summary.active_users_last_24h}
/>
</div>
<section className="scraper-section">
<h2>Динамика по дням</h2>
<p className="scraper-hint">События и уникальные пользователи в день.</p>
{chartData.length < 2 ? (
<p className="scraper-hint" style={{ margin: 0 }}>
Недостаточно данных для графика нужно минимум 2 дня наблюдений.
</p>
) : (
<div aria-hidden="true" style={{ width: "100%", height: 260 }}>
<ResponsiveContainer>
<LineChart data={chartData} margin={{ top: 8, right: 16, left: 8, bottom: 8 }}>
<CartesianGrid stroke="var(--border-card, #e6e8ec)" strokeDasharray="3 3" />
<XAxis dataKey="label" tick={{ fontSize: 11 }} />
<YAxis tick={{ fontSize: 11 }} allowDecimals={false} />
<Tooltip labelFormatter={(label: string) => label} />
<Line
type="monotone"
dataKey="events"
stroke="var(--accent, #1d4ed8)"
strokeWidth={2}
dot={{ r: 2 }}
name="События"
/>
<Line
type="monotone"
dataKey="users"
stroke="var(--success, #0a7a3a)"
strokeWidth={2}
dot={{ r: 2 }}
name="Пользователи"
/>
</LineChart>
</ResponsiveContainer>
</div>
)}
</section>
<div className="admin-two-col">
<section className="scraper-section">
<h2>Топ поисковых адресов</h2>
{analyticsQ.data.top_searches.length === 0 ? (
<p className="scraper-hint" style={{ margin: 0 }}>
Пока нет данных.
</p>
) : (
<table className="runs-table">
<thead>
<tr>
<th scope="col">Адрес</th>
<th scope="col">Поисков</th>
</tr>
</thead>
<tbody>
{analyticsQ.data.top_searches.map((row, i) => (
<tr key={i}>
<td>{row.address ?? "—"}</td>
<td>{row.n}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
<section className="scraper-section">
<h2>Топ API-путей</h2>
{analyticsQ.data.top_paths.length === 0 ? (
<p className="scraper-hint" style={{ margin: 0 }}>
Пока нет данных.
</p>
) : (
<table className="runs-table">
<thead>
<tr>
<th scope="col">Путь</th>
<th scope="col">Запросов</th>
</tr>
</thead>
<tbody>
{analyticsQ.data.top_paths.map((row, i) => (
<tr key={i}>
<td>{row.path ?? "—"}</td>
<td>{row.n}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</div>
<section className="scraper-section">
<h2>Активность по аккаунтам</h2>
{analyticsQ.data.by_account.length === 0 ? (
<p className="scraper-hint" style={{ margin: 0 }}>
Пока нет данных.
</p>
) : (
<table className="runs-table">
<thead>
<tr>
<th scope="col">Аккаунт</th>
<th scope="col">События</th>
<th scope="col">Поиски</th>
<th scope="col">Последняя активность</th>
</tr>
</thead>
<tbody>
{analyticsQ.data.by_account.map((row) => (
<tr key={row.username}>
<td>{row.username}</td>
<td>{row.events}</td>
<td>{row.searches}</td>
<td className="run-muted">{formatDateTime(row.last_seen)}</td>
</tr>
))}
</tbody>
</table>
)}
</section>
</>
)}
</main>
</>
);
}

View file

@ -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<T>({
title,
rows,
render,
}: {
title: string;
rows: T[];
render: (row: T, i: number) => ReactNode;
}) {
return (
<div>
<h4>
{title} ({rows.length})
</h4>
{rows.length === 0 ? (
<p className="scraper-hint" style={{ margin: 0 }}>
Нет данных.
</p>
) : (
<table className="runs-table">
<tbody>{rows.map(render)}</tbody>
</table>
)}
</div>
);
}
function AccountDrilldownPanel({ username }: { username: string }) {
const drilldownQ = useAccountDrilldown(username);
if (drilldownQ.isError) {
return (
<p className="scraper-result scraper-result--error" style={{ margin: "12px 6px" }}>
Не удалось загрузить детали аккаунта: {drilldownQ.error.message}
</p>
);
}
const d: AccountDrilldown | undefined = drilldownQ.data;
if (!d) {
return (
<p className="scraper-hint" style={{ margin: "12px 6px" }}>
Загрузка деталей
</p>
);
}
const hasAnyData =
d.ips.length > 0 ||
d.devices.length > 0 ||
d.searches.length > 0 ||
d.recent_activity.length > 0;
if (!hasAnyData) {
return (
<p className="scraper-hint" style={{ margin: "12px 6px" }}>
Пока нет данных по этому аккаунту.
</p>
);
}
return (
<div className="admin-drilldown">
<DrilldownSection
title="IP-адреса"
rows={d.ips}
render={(row, i) => (
<tr key={i}>
<td>{row.ip_address ?? "—"}</td>
<td className="run-muted">{row.event_count} событ.</td>
<td className="run-muted">
{formatDateTime(row.first_seen)} {formatDateTime(row.last_seen)}
</td>
</tr>
)}
/>
<DrilldownSection
title="Устройства"
rows={d.devices}
render={(row, i) => (
<tr key={i}>
<td style={{ maxWidth: 260, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{row.user_agent ?? "—"}
</td>
<td className="run-muted">{row.event_count} событ.</td>
<td className="run-muted">{formatDateTime(row.last_seen)}</td>
</tr>
)}
/>
<DrilldownSection
title="Поиски"
rows={d.searches}
render={(row, i) => (
<tr key={i}>
<td>{row.address ?? "—"}</td>
<td className="run-muted">
{row.rooms ?? "?"}к · {row.area_m2 ?? "?"} м²
</td>
<td className="run-muted">
{formatDateTime(row.created_at)} · {row.ip_address ?? "—"}
</td>
</tr>
)}
/>
<DrilldownSection
title="Недавняя активность"
rows={d.recent_activity}
render={(row, i) => (
<tr key={i}>
<td>{row.event_type}</td>
<td className="run-muted">
{row.method ?? ""} {row.path ?? "—"}
</td>
<td className="run-muted">{formatDateTime(row.created_at)}</td>
</tr>
)}
/>
</div>
);
}
export default function AdminAuditPage() {
const accountsQ = useAuditAccounts();
const [expandedUser, setExpandedUser] = useState<string | null>(null);
// Backend 403-ит non-admin регардлесс (nav уже прячет ссылку) — прямой заход
// на URL не должен крашить страницу, показываем тот же экран, что RouteGuard
// рендерит для path-deny.
if (accountsQ.error instanceof HTTPError && accountsQ.error.status === 403) {
return <NoAccessScreen variant="user" />;
}
return (
<>
<Topbar active="audit" />
<main className="page scraper-page" style={{ maxWidth: 1180 }}>
<h1 className="scraper-h1">Аудит доступа</h1>
<p className="scraper-subtitle">
С каких IP-адресов и устройств заходят аккаунты и что они ищут. Клик по
аккаунту раскрывает детали.
</p>
<section className="scraper-section">
{accountsQ.isPending && <p className="scraper-hint">Загрузка</p>}
{accountsQ.isError && (
<p className="scraper-result scraper-result--error">
Ошибка загрузки: {accountsQ.error.message}
</p>
)}
{accountsQ.data && accountsQ.data.length === 0 && (
<p className="scraper-hint">
Пока нет данных событий аудита ещё не собрано.
</p>
)}
{accountsQ.data && accountsQ.data.length > 0 && (
<table className="runs-table">
<thead>
<tr>
<th scope="col">Аккаунт</th>
<th scope="col">Последний визит</th>
<th scope="col">Первый визит</th>
<th scope="col">IP</th>
<th scope="col">Устройства</th>
<th scope="col">Входы</th>
<th scope="col">Запросы</th>
<th scope="col">Поиски</th>
</tr>
</thead>
<tbody>
{accountsQ.data.map((a) => {
const isOpen = expandedUser === a.username;
return (
<Fragment key={a.username}>
<tr>
<td>
<button
type="button"
className="admin-row-toggle"
aria-expanded={isOpen}
onClick={() => setExpandedUser(isOpen ? null : a.username)}
>
{isOpen ? "▾" : "▸"} {a.username}
</button>
</td>
<td className="run-muted">{formatDateTime(a.last_seen_at)}</td>
<td className="run-muted">{formatDateTime(a.first_seen_at)}</td>
<td>{a.distinct_ips}</td>
<td>{a.distinct_devices}</td>
<td>{a.login_count}</td>
<td>{a.request_count}</td>
<td>{a.search_count}</td>
</tr>
{isOpen && (
<tr>
<td colSpan={8} style={{ background: "var(--bg-card-alt, #fafbfc)" }}>
<AccountDrilldownPanel username={a.username} />
</td>
</tr>
)}
</Fragment>
);
})}
</tbody>
</table>
)}
</section>
</main>
</>
);
}

View file

@ -82,7 +82,9 @@ export type ActiveTab =
| "scrapers" | "scrapers"
| "avito" | "avito"
| "cian" | "cian"
| "yandex"; | "yandex"
| "audit"
| "analytics";
interface TopbarProps { interface TopbarProps {
active: ActiveTab; active: ActiveTab;
@ -145,6 +147,21 @@ const NAV_ITEMS: Array<{
scopePath: "/trade-in/api/v1/admin/scrapers/yandex", scopePath: "/trade-in/api/v1/admin/scrapers/yandex",
label: "Яндекс", 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) { export function Topbar({ active }: TopbarProps) {

View file

@ -2732,3 +2732,91 @@ html, body { overflow-x: clip; }
.ss-hist { height: 76px; } .ss-hist { height: 76px; }
.ss-map, .ss-map-empty { height: 360px; } .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;
}

View file

@ -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<AccountSummary[]>({
queryKey: ["admin", "audit", "accounts"],
queryFn: () => apiFetch<AccountSummary[]>(`${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<AccountDrilldown>({
queryKey: ["admin", "audit", "accounts", username],
queryFn: () =>
apiFetch<AccountDrilldown>(
`${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<AnalyticsDashboard>({
queryKey: ["admin", "analytics", days],
queryFn: () => apiFetch<AnalyticsDashboard>(`${BASE}/analytics?days=${days}`),
staleTime: 30_000,
retry: false,
});
}