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"
|
||||
| "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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
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