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
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:
parent
9fd6396fbc
commit
c904fbf94e
5 changed files with 761 additions and 1 deletions
252
tradein-mvp/frontend/src/app/admin/analytics/page.tsx
Normal file
252
tradein-mvp/frontend/src/app/admin/analytics/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
248
tradein-mvp/frontend/src/app/admin/audit/page.tsx
Normal file
248
tradein-mvp/frontend/src/app/admin/audit/page.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
||||||
155
tradein-mvp/frontend/src/lib/admin-audit-api.ts
Normal file
155
tradein-mvp/frontend/src/lib/admin-audit-api.ts
Normal 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue