events & global sync. unstable

This commit is contained in:
unexpected 2025-10-15 16:57:47 +00:00
parent 997fbb6985
commit 3b31c4e6cb
10 changed files with 691 additions and 27 deletions

View File

@ -9,6 +9,7 @@ import {
AdminStoragePage,
AdminUploadsPage,
AdminUsersPage,
AdminEventsPage,
AdminLicensesPage,
AdminStarsPage,
AdminSystemPage,
@ -50,6 +51,7 @@ const router = createBrowserRouter([
{ path: "overview", element: <AdminOverviewPage /> },
{ path: "storage", element: <AdminStoragePage /> },
{ path: "uploads", element: <AdminUploadsPage /> },
{ path: "events", element: <AdminEventsPage /> },
{ path: "users", element: <AdminUsersPage /> },
{ path: "licenses", element: <AdminLicensesPage /> },
{ path: "stars", element: <AdminStarsPage /> },

View File

@ -1,4 +1,4 @@
import { useCallback } from "react";
import { ReactNode, useCallback } from "react";
import clsx from "clsx";
import { useAdminContext } from "../context";
@ -8,6 +8,7 @@ type CopyButtonProps = {
className?: string;
"aria-label"?: string;
successMessage?: string;
children?: ReactNode;
};
const copyFallback = (text: string) => {
@ -29,7 +30,7 @@ const copyFallback = (text: string) => {
}
};
export const CopyButton = ({ value, className, "aria-label": ariaLabel, successMessage }: CopyButtonProps) => {
export const CopyButton = ({ value, className, "aria-label": ariaLabel, successMessage, children }: CopyButtonProps) => {
const { pushFlash } = useAdminContext();
const handleCopy = useCallback(async () => {
@ -59,12 +60,14 @@ export const CopyButton = ({ value, className, "aria-label": ariaLabel, successM
<button
type="button"
className={clsx(
"inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-slate-700 bg-slate-900 text-slate-300 transition hover:border-sky-500 hover:text-white focus:outline-none focus:ring-2 focus:ring-sky-500/40",
"inline-flex shrink-0 items-center justify-center rounded-md border border-slate-700 bg-slate-900 text-slate-300 transition hover:border-sky-500 hover:text-white focus:outline-none focus:ring-2 focus:ring-sky-500/40",
children ? "h-7 px-3" : "h-7 w-7",
className,
)}
aria-label={ariaLabel ?? "Скопировать значение"}
onClick={handleCopy}
>
{children ?? (
<svg
xmlns="http://www.w3.org/2000/svg"
className="h-4 w-4"
@ -78,6 +81,7 @@ export const CopyButton = ({ value, className, "aria-label": ariaLabel, successM
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</button>
);
};

View File

@ -4,6 +4,7 @@ export type AdminSectionId =
| "overview"
| "storage"
| "uploads"
| "events"
| "users"
| "licenses"
| "stars"
@ -24,6 +25,7 @@ export const ADMIN_SECTIONS: AdminSection[] = [
{ id: "overview", label: "Обзор", description: "Краткий срез состояния узла, окружения и служб", path: "overview" },
{ id: "storage", label: "Хранилище", description: "Загруженность диска и директории контента", path: "storage" },
{ id: "uploads", label: "Загрузки", description: "Отслеживание статуса загрузок и деривативов", path: "uploads" },
{ id: "events", label: "События", description: "Журнал действий ноды и сети", path: "events" },
{ id: "users", label: "Пользователи", description: "Мониторинг пользователей, кошельков и активности", path: "users" },
{ id: "licenses", label: "Лицензии", description: "Статус лицензий и фильтрация по типам", path: "licenses" },
{ id: "stars", label: "Платежи Stars", description: "Управление счетами и анализ платежей", path: "stars" },

View File

@ -0,0 +1,307 @@
import { useEffect, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Routes } from "~/app/router/constants";
import { useAdminEvents } from "~/shared/services/admin";
import { Section, PaginationControls, CopyButton, Badge } from "../components";
import { useAdminContext } from "../context";
import { formatDate, numberFormatter } from "../utils/format";
const EVENT_TYPE_LABELS: Record<string, string> = {
content_uploaded: "Загрузка контента",
content_indexed: "Индексация контента",
stars_payment: "Платёж Stars",
node_registered: "Регистрация ноды",
user_role_changed: "Изменение ролей",
};
const EVENT_STATUS_TONES: Record<string, "success" | "warn" | "danger" | "neutral"> = {
local: "neutral",
recorded: "neutral",
processing: "warn",
applied: "success",
failed: "danger",
};
const linkLabels: Record<string, string> = {
admin_uploads: "К загрузкам",
content_view: "Просмотр контента",
admin_stars: "К платежам",
admin_user: "К пользователю",
};
export const AdminEventsPage = () => {
const { isAuthorized, handleRequestError } = useAdminContext();
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<string | null>(null);
const [originFilter, setOriginFilter] = useState<string | null>(null);
const [page, setPage] = useState(0);
const limit = 50;
const navigate = useNavigate();
const normalizedSearch = search.trim() || null;
useEffect(() => {
setPage(0);
}, [normalizedSearch, typeFilter, statusFilter, originFilter]);
const eventsQuery = useAdminEvents(
{
limit,
offset: page * limit,
search: normalizedSearch || undefined,
type: typeFilter || undefined,
status: statusFilter || undefined,
origin: originFilter || undefined,
},
{
enabled: isAuthorized,
keepPreviousData: true,
refetchInterval: 60_000,
onError: (error) => handleRequestError(error, "Не удалось загрузить события"),
},
);
const availableFilters = eventsQuery.data?.available_filters ?? {
types: {},
statuses: {},
origins: {},
};
const renderLink = (key: string, url: string | null) => {
if (!url) {
return null;
}
const label = linkLabels[key] ?? key;
if (url.startsWith("http")) {
return (
<a
key={`${key}-${url}`}
href={url}
target="_blank"
rel="noopener noreferrer"
className="rounded-lg border border-sky-500/40 px-3 py-1 text-[11px] font-semibold text-sky-100 transition hover:border-sky-400 hover:text-white"
>
{label}
</a>
);
}
const normalized = url.startsWith("/") ? url.slice(1) : url;
return (
<button
key={`${key}-${url}`}
type="button"
onClick={() => navigate(`${Routes.Admin}/${normalized}`)}
className="rounded-lg border border-slate-700 px-3 py-1 text-[11px] font-semibold text-slate-200 transition hover:border-slate-500 hover:text-white"
>
{label}
</button>
);
};
const events = eventsQuery.data?.items ?? [];
const total = eventsQuery.data?.total ?? 0;
return (
<Section
id="events"
title="События"
description="Журнал контента, платежей и сетевых операций"
actions={
<button
type="button"
className="rounded-lg bg-slate-800 px-3 py-2 text-xs font-semibold text-slate-200 ring-1 ring-slate-700 transition hover:bg-slate-700"
onClick={() => eventsQuery.refetch()}
disabled={eventsQuery.isFetching}
>
{eventsQuery.isFetching ? "Обновляем…" : "Обновить"}
</button>
}
>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<div className="overflow-hidden rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800 shadow-inner shadow-black/40">
<p className="text-xs uppercase tracking-wide text-slate-500">Всего событий</p>
<p className="mt-2 text-lg font-semibold text-slate-100">{numberFormatter.format(total)}</p>
<p className="mt-1 text-xs text-slate-500">На странице {numberFormatter.format(events.length)}</p>
</div>
<div className="overflow-hidden rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800 shadow-inner shadow-black/40">
<p className="text-xs uppercase tracking-wide text-slate-500">Типы</p>
<p className="mt-2 text-lg font-semibold text-slate-100">{Object.keys(availableFilters.types).length}</p>
<p className="mt-1 text-xs text-slate-500">Фильтр по типу помогает сузить список</p>
</div>
<div className="overflow-hidden rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800 shadow-inner shadow-black/40">
<p className="text-xs uppercase tracking-wide text-slate-500">Статусы</p>
<p className="mt-2 text-lg font-semibold text-slate-100">{Object.keys(availableFilters.statuses).length}</p>
<p className="mt-1 text-xs text-slate-500">Например, только требующие действий</p>
</div>
<div className="overflow-hidden rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800 shadow-inner shadow-black/40">
<p className="text-xs uppercase tracking-wide text-slate-500">Источники</p>
<p className="mt-2 text-lg font-semibold text-slate-100">{Object.keys(availableFilters.origins).length}</p>
<p className="mt-1 text-xs text-slate-500">Отслеживайте trusted-нод</p>
</div>
</div>
<div className="mt-6 grid gap-3 md:grid-cols-4">
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Поиск</label>
<input
value={search}
onChange={(event) => setSearch(event.target.value)}
placeholder="UID, CID, invoice, json"
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-500 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Тип</label>
<select
value={typeFilter ?? ""}
onChange={(event) => setTypeFilter(event.target.value || null)}
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
>
<option value="">Все типы</option>
{Object.entries(availableFilters.types).map(([type, count]) => (
<option key={`event-type-${type}`} value={type}>
{EVENT_TYPE_LABELS[type] ?? type} {numberFormatter.format(count)}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Статус</label>
<select
value={statusFilter ?? ""}
onChange={(event) => setStatusFilter(event.target.value || null)}
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
>
<option value="">Все статусы</option>
{Object.entries(availableFilters.statuses).map(([status, count]) => (
<option key={`event-status-${status}`} value={status}>
{status} {numberFormatter.format(count)}
</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Источник</label>
<select
value={originFilter ?? ""}
onChange={(event) => setOriginFilter(event.target.value || null)}
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
>
<option value="">Все источники</option>
{Object.entries(availableFilters.origins).map(([origin, count]) => (
<option key={`event-origin-${origin}`} value={origin}>
{origin} {numberFormatter.format(count)}
</option>
))}
</select>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
<button
type="button"
className="rounded-lg bg-slate-900 px-3 py-2 text-xs font-semibold text-slate-300 ring-1 ring-slate-700 transition hover:bg-slate-800"
onClick={() => {
setSearch("");
setTypeFilter(null);
setStatusFilter(null);
setOriginFilter(null);
}}
>
Сбросить фильтры
</button>
</div>
{eventsQuery.isLoading && !eventsQuery.data ? (
<div className="mt-6 rounded-2xl border border-slate-800 bg-slate-950/40 px-4 py-6 text-center text-sm text-slate-400">
Загружаем события
</div>
) : null}
<div className="mt-6 grid gap-4">
{events.length === 0 ? (
<div className="rounded-2xl border border-slate-800 bg-slate-950/40 px-4 py-6 text-center text-sm text-slate-400">
События не найдены.
</div>
) : (
events.map((event) => {
const typeLabel = EVENT_TYPE_LABELS[event.event_type] ?? event.event_type;
const statusTone = EVENT_STATUS_TONES[event.status] ?? 'neutral';
const statusBadgeTone: "success" | "warn" | "danger" | "neutral" = statusTone;
const linkEntries = Object.entries(event.links ?? {});
return (
<div key={`event-${event.id}`} className="rounded-2xl border border-slate-800 bg-slate-950/50 p-5 shadow-inner shadow-black/40">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex flex-col gap-2">
<div className="flex flex-wrap items-center gap-2 text-sm text-slate-200">
<span className="font-semibold">#{numberFormatter.format(event.seq)}</span>
<Badge tone="neutral">{typeLabel}</Badge>
<Badge tone={statusBadgeTone}>{event.status}</Badge>
</div>
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-400">
<span>UID: {event.uid}</span>
<CopyButton
value={event.uid}
aria-label="Скопировать UID"
successMessage="UID скопирован"
className="h-6 w-6"
/>
</div>
</div>
<div className="text-xs text-slate-400 text-right">
<div>Создано: {formatDate(event.created_at)}</div>
<div>Получено: {formatDate(event.received_at)}</div>
<div>Применено: {formatDate(event.applied_at)}</div>
</div>
</div>
<div className="mt-3 grid gap-2 text-xs text-slate-300 md:grid-cols-2">
<div>
<span className="text-slate-500">Источник</span>
<div className="mt-1 flex flex-wrap items-center gap-2 break-all text-[11px] text-slate-300">
<span>{event.origin_public_key ?? "—"}</span>
{event.origin_public_key ? (
<CopyButton
value={event.origin_public_key}
aria-label="Скопировать ключ"
successMessage="Публичный ключ скопирован"
className="h-6 w-6"
/>
) : null}
</div>
<div className="text-[11px] text-slate-500">{event.origin_host ?? "—"}</div>
</div>
<div>
<span className="text-slate-500">Ссылки</span>
{linkEntries.length === 0 ? (
<div className="mt-1 text-[11px] text-slate-500">Нет прямых ссылок</div>
) : (
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs">
{linkEntries.map(([key, url]) => renderLink(key, url))}
</div>
)}
</div>
</div>
<details className="mt-4 rounded-xl border border-slate-800 bg-slate-950/40">
<summary className="cursor-pointer px-4 py-2 text-xs font-semibold text-slate-200">Показать payload</summary>
<pre className="max-h-60 overflow-auto px-4 py-3 text-[11px] leading-relaxed text-slate-200">
{JSON.stringify(event.payload ?? {}, null, 2)}
</pre>
</details>
</div>
);
})
)}
</div>
<div className="mt-6">
<PaginationControls
total={total}
limit={limit}
page={page}
onPageChange={(next) => setPage(next)}
/>
</div>
</Section>
);
};

View File

@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useLocation } from "react-router-dom";
import { useAdminStars } from "~/shared/services/admin";
import { Section, PaginationControls, Badge } from "../components";
@ -7,7 +8,12 @@ import { formatDate, formatStars, numberFormatter } from "../utils/format";
export const AdminStarsPage = () => {
const { isAuthorized, handleRequestError } = useAdminContext();
const [search, setSearch] = useState("");
const location = useLocation();
const initialSearch = useMemo(() => {
const params = new URLSearchParams(location.search);
return params.get("search") ?? "";
}, [location.search]);
const [search, setSearch] = useState(initialSearch);
const [paidFilter, setPaidFilter] = useState<"all" | "paid" | "unpaid">("all");
const [typeFilter, setTypeFilter] = useState("all");
const [page, setPage] = useState(0);
@ -31,6 +37,10 @@ export const AdminStarsPage = () => {
},
);
useEffect(() => {
setSearch(initialSearch);
}, [initialSearch]);
useEffect(() => {
setPage(0);
}, [normalizedSearch, paidFilter, typeFilter]);

View File

@ -1,5 +1,5 @@
import { useAdminSystem } from "~/shared/services/admin";
import { Section, InfoRow, Badge } from "../components";
import { Section, InfoRow, Badge, CopyButton } from "../components";
import { useAdminContext } from "../context";
import { formatDate, formatUnknown, numberFormatter } from "../utils/format";
@ -20,7 +20,8 @@ export const AdminSystemPage = () => {
) : null;
}
const { env, service_config, services, blockchain_tasks, latest_index_items } = systemQuery.data;
const { env, service_config, services, blockchain_tasks, latest_index_items, telegram_bots } = systemQuery.data;
const bots = telegram_bots ?? [];
return (
<Section id="system" title="Система" description="Ключевые переменные и внутренние конфиги">
@ -35,6 +36,43 @@ export const AdminSystemPage = () => {
))}
</dl>
</div>
<div className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-400">Telegram-боты</h3>
{bots.length === 0 ? (
<p className="mt-3 text-xs text-slate-500">Боты не настроены.</p>
) : (
<ul className="mt-3 space-y-3 text-sm text-slate-200">
{bots.map((bot) => (
<li
key={`${bot.role}-${bot.username}`}
className="flex flex-col gap-2 rounded-lg border border-slate-800 bg-slate-900/50 px-3 py-3"
>
<div className="flex flex-wrap items-center gap-3">
<Badge tone="neutral">{bot.role}</Badge>
<span className="font-semibold">@{bot.username}</span>
<CopyButton
value={bot.username}
aria-label="Скопировать username бота"
successMessage="Username скопирован"
className="h-6 w-6"
/>
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-slate-400">
<span>t.me/{bot.username}</span>
<a
href={bot.url}
target="_blank"
rel="noopener noreferrer"
className="rounded-lg border border-sky-500/40 px-3 py-1 text-[11px] font-semibold text-sky-200 transition hover:border-sky-400 hover:text-white"
>
Открыть бота
</a>
</div>
</li>
))}
</ul>
)}
</div>
<div className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-400">Задачи блокчейна</h3>
<dl className="mt-3 grid grid-cols-2 gap-3">

View File

@ -1,5 +1,6 @@
import { useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import clsx from "clsx";
import { useLocation } from "react-router-dom";
import {
useAdminUploads,
@ -118,6 +119,9 @@ const classifyUpload = (item: AdminUploadsContent): UploadDecoration => {
const user = item.stored.user;
searchParts.push(user.id, user.telegram_id, user.username, user.first_name, user.last_name);
}
item.distribution?.nodes?.forEach((node) => {
searchParts.push(node.host, node.public_host, node.content.encrypted_cid);
});
const searchText = searchParts
.filter((value) => value !== null && value !== undefined && `${value}`.length > 0)
.map((value) => `${value}`.toLowerCase())
@ -166,6 +170,7 @@ const UploadCard = ({ decoration }: { decoration: UploadDecoration }) => {
const { item, flags } = decoration;
const problemMessages = decoration.hasIssue ? getProblemMessages(item) : [];
const derivativeDownloads = item.links.download_derivatives ?? [];
const distributionNodes = item.distribution?.nodes ?? [];
return (
<div className="overflow-hidden rounded-2xl border border-slate-800 bg-slate-950/40 p-5 shadow-inner shadow-black/40">
@ -235,6 +240,79 @@ const UploadCard = ({ decoration }: { decoration: UploadDecoration }) => {
</div>
</div>
{distributionNodes.length > 0 ? (
<div className="rounded-xl bg-slate-900/60 p-4 ring-1 ring-slate-800">
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Синхронизация</h4>
<ul className="mt-3 space-y-3 text-xs text-slate-300">
{distributionNodes.map((node) => {
const nodeTitle = node.is_local ? "Эта нода" : node.public_host ?? node.host ?? "Неизвестная нода";
const sizeLabel = node.content.size_bytes ? formatBytes(node.content.size_bytes) : null;
const updatedLabel = node.content.updated_at ? formatDate(node.content.updated_at) : null;
const lastSeenLabel = node.last_seen ? formatDate(node.last_seen) : null;
return (
<li key={`${item.encrypted_cid}-${node.node_id ?? "local"}`} className="rounded-lg border border-slate-800 bg-slate-950/50 p-3">
<div className="flex flex-wrap items-start justify-between gap-2">
<div className="space-y-1">
<div className="flex flex-wrap items-center gap-2 text-[11px] uppercase tracking-wide text-slate-400">
<span className="font-semibold text-slate-200">{nodeTitle}</span>
<Badge tone={node.is_local ? "success" : "neutral"}>{node.is_local ? "локально" : "удаленно"}</Badge>
{node.role ? <Badge tone="neutral">{node.role}</Badge> : null}
</div>
<div className="space-y-1 text-[11px] text-slate-400">
{sizeLabel ? <p>Размер: {sizeLabel}</p> : null}
{updatedLabel ? <p>Обновлено: {updatedLabel}</p> : null}
{lastSeenLabel && !node.is_local ? <p>Нода активна: {lastSeenLabel}</p> : null}
{node.version ? <p>Версия: {node.version}</p> : null}
</div>
</div>
<div className="flex flex-wrap items-center gap-2">
{node.links.web_view ? (
<a
href={node.links.web_view}
target="_blank"
rel="noopener noreferrer"
className="rounded-lg border border-emerald-500/40 px-3 py-1 text-[11px] font-semibold text-emerald-200 transition hover:border-emerald-400 hover:text-emerald-100"
>
Web
</a>
) : null}
{node.links.api_view ? (
<>
<a
href={node.links.api_view}
target="_blank"
rel="noopener noreferrer"
className="rounded-lg border border-indigo-500/40 px-3 py-1 text-[11px] font-semibold text-indigo-200 transition hover:border-indigo-400 hover:text-indigo-100"
>
API
</a>
<CopyButton
className="border border-indigo-500/40 text-indigo-200 hover:border-indigo-400 hover:text-indigo-100"
value={node.links.api_view}
>
копировать API
</CopyButton>
</>
) : null}
{node.links.gateway_view ? (
<a
href={node.links.gateway_view}
target="_blank"
rel="noopener noreferrer"
className="rounded-lg border border-amber-500/40 px-3 py-1 text-[11px] font-semibold text-amber-200 transition hover:border-amber-400 hover:text-amber-100"
>
Gateway
</a>
) : null}
</div>
</div>
</li>
);
})}
</ul>
</div>
) : null}
<div className="rounded-xl bg-slate-900/60 p-4 ring-1 ring-slate-800">
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">История загрузки</h4>
<ul className="mt-2 space-y-2 text-xs text-slate-300">
@ -397,8 +475,17 @@ const UploadCard = ({ decoration }: { decoration: UploadDecoration }) => {
export const AdminUploadsPage = () => {
const { isAuthorized, handleRequestError } = useAdminContext();
const location = useLocation();
const [uploadsFilter, setUploadsFilter] = useState<UploadFilter>("all");
const [uploadsSearch, setUploadsSearch] = useState("");
const initialUploadsSearch = useMemo(() => {
const params = new URLSearchParams(location.search);
return params.get("search") ?? "";
}, [location.search]);
const [uploadsSearch, setUploadsSearch] = useState(initialUploadsSearch);
useEffect(() => {
setUploadsSearch(initialUploadsSearch);
}, [initialUploadsSearch]);
const normalizedUploadsSearch = uploadsSearch.trim();
const hasUploadsSearch = normalizedUploadsSearch.length > 0;

View File

@ -1,14 +1,21 @@
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { useLocation } from "react-router-dom";
import { useAdminUsers } from "~/shared/services/admin";
import { Section, PaginationControls, CopyButton } from "../components";
import { useAdminUsers, useAdminSetUserAdmin } from "~/shared/services/admin";
import { Section, PaginationControls, CopyButton, Badge } from "../components";
import { useAdminContext } from "../context";
import { formatDate, formatStars, numberFormatter } from "../utils/format";
export const AdminUsersPage = () => {
const { isAuthorized, handleRequestError } = useAdminContext();
const [search, setSearch] = useState("");
const { isAuthorized, handleRequestError, pushFlash } = useAdminContext();
const location = useLocation();
const initialSearchValue = useMemo(() => {
const params = new URLSearchParams(location.search);
return params.get("search") ?? "";
}, [location.search]);
const [search, setSearch] = useState(initialSearchValue);
const [page, setPage] = useState(0);
const [pendingUserId, setPendingUserId] = useState<number | null>(null);
const limit = 50;
const normalizedSearch = search.trim();
@ -27,6 +34,33 @@ export const AdminUsersPage = () => {
},
);
const setUserAdmin = useAdminSetUserAdmin({
onMutate: ({ user_id }) => {
setPendingUserId(user_id);
},
onSuccess: ({ user }) => {
pushFlash({
type: "success",
message: user.is_admin ? "Пользователь назначен администратором" : "Права администратора сняты",
});
void usersQuery.refetch();
},
onError: (error) => {
handleRequestError(error, "Не удалось обновить права администратора");
},
onSettled: () => {
setPendingUserId(null);
},
});
const handleToggleAdmin = (userId: number, nextValue: boolean) => {
setUserAdmin.mutate({ user_id: userId, is_admin: nextValue });
};
useEffect(() => {
setSearch(initialSearchValue);
}, [initialSearchValue]);
useEffect(() => {
setPage(0);
}, [normalizedSearch]);
@ -54,6 +88,11 @@ export const AdminUsersPage = () => {
value: numberFormatter.format(total),
helper: `На странице: ${numberFormatter.format(summary.users_returned)}`,
},
{
label: "Администраторы",
value: numberFormatter.format(summary.admins_total ?? 0),
helper: summary.admins_total ? "Имеют доступ к /admin" : "Назначьте администраторов",
},
{
label: "Кошельки (активные/всего)",
value: `${numberFormatter.format(summary.wallets_active)} / ${numberFormatter.format(summary.wallets_total)}`,
@ -165,6 +204,23 @@ export const AdminUsersPage = () => {
{(user.first_name ?? "")} {(user.last_name ?? "")}
</div>
) : null}
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
<Badge tone={user.is_admin ? "success" : "neutral"}>
{user.is_admin ? "Администратор" : "Пользователь"}
</Badge>
<button
type="button"
onClick={() => handleToggleAdmin(user.id, !user.is_admin)}
className="rounded-lg border border-slate-700 px-3 py-1 text-[11px] font-semibold text-slate-200 transition hover:border-slate-500 hover:text-white disabled:cursor-not-allowed disabled:opacity-60"
disabled={setUserAdmin.isLoading}
>
{pendingUserId === user.id && setUserAdmin.isLoading
? "Сохраняем…"
: user.is_admin
? "Снять права"
: "Назначить админом"}
</button>
</div>
</td>
<td className="px-3 py-3 align-top">
<div className="text-xs text-slate-400">
@ -291,6 +347,26 @@ export const AdminUsersPage = () => {
{numberFormatter.format(user.licenses.active)}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-slate-500">Роль</span>
<div className="flex flex-wrap items-center gap-2">
<Badge tone={user.is_admin ? "success" : "neutral"}>
{user.is_admin ? "Администратор" : "Пользователь"}
</Badge>
<button
type="button"
onClick={() => handleToggleAdmin(user.id, !user.is_admin)}
className="rounded-lg border border-slate-700 px-3 py-1 text-[11px] font-semibold text-slate-200 transition hover:border-slate-500 hover:text-white disabled:cursor-not-allowed disabled:opacity-60"
disabled={setUserAdmin.isLoading}
>
{pendingUserId === user.id && setUserAdmin.isLoading
? "Сохраняем…"
: user.is_admin
? "Снять права"
: "Назначить админом"}
</button>
</div>
</div>
<div className="flex flex-col gap-1">
<span className="text-slate-500">Последняя активность</span>
{user.ip_activity.last ? (

View File

@ -1,6 +1,7 @@
export { AdminOverviewPage } from "./Overview";
export { AdminStoragePage } from "./Storage";
export { AdminUploadsPage } from "./Uploads";
export { AdminEventsPage } from "./Events";
export { AdminUsersPage } from "./Users";
export { AdminLicensesPage } from "./Licenses";
export { AdminStarsPage } from "./Stars";

View File

@ -143,6 +143,35 @@ export type AdminUploadsContentFlags = {
unindexed: boolean;
};
export type AdminContentDistributionNode = {
node_id: number | null;
is_local: boolean;
host: string | null;
public_host: string | null;
version: string | null;
role: string | null;
last_seen: string | null;
content: {
encrypted_cid: string | null;
content_type: string | null;
size_bytes: number | null;
preview_enabled: boolean | null;
updated_at: string | null;
metadata_cid?: string | null;
issuer_node_id?: string | null;
};
links: {
web_view: string | null;
api_view: string | null;
gateway_view: string | null;
};
};
export type AdminContentDistribution = {
local_present: boolean;
nodes: AdminContentDistributionNode[];
};
export type AdminUploadsContentStatus = {
upload_state: string | null;
conversion_state: string | null;
@ -175,6 +204,7 @@ export type AdminUploadsContent = {
ipfs: AdminUploadsContentIpfs;
stored: AdminUploadsContentStored;
links: AdminUploadsContentLinks;
distribution: AdminContentDistribution;
flags?: AdminUploadsContentFlags;
};
@ -264,6 +294,7 @@ export type AdminUserIpActivity = {
export type AdminUserItem = {
id: number;
is_admin: boolean;
telegram_id: number;
username: string | null;
first_name: string | null;
@ -283,8 +314,48 @@ export type AdminUserItem = {
ip_activity: AdminUserIpActivity;
};
export type AdminEventItem = {
id: number;
origin_public_key: string | null;
origin_host: string | null;
seq: number;
uid: string;
event_type: string;
status: string;
created_at: string | null;
received_at: string | null;
applied_at: string | null;
payload: Record<string, unknown>;
links: Record<string, string | null>;
};
export type AdminEventsResponse = {
total: number;
limit: number;
offset: number;
filters?: Record<string, unknown>;
items: AdminEventItem[];
available_filters: {
types: Record<string, number>;
statuses: Record<string, number>;
origins: Record<string, number>;
};
};
export type AdminEventsQueryParams = {
limit?: number;
offset?: number;
type?: string | string[];
status?: string | string[];
origin?: string | string[];
search?: string;
};
type AdminEventsQueryKey = ['admin', 'events', string];
export type AdminUsersSummary = {
users_returned: number;
admins_total: number;
wallets_total: number;
wallets_active: number;
licenses_total: number;
@ -472,6 +543,11 @@ export type AdminSystemResponse = {
encrypted_cid: string | null;
updated_at: string;
}>;
telegram_bots: Array<{
role: string;
username: string;
url: string;
}>;
};
export type AdminBlockchainResponse = {
@ -773,6 +849,55 @@ export const useAdminStars = (
);
};
export const useAdminEvents = (
params?: AdminEventsQueryParams,
options?: QueryOptions<AdminEventsResponse, AdminEventsQueryKey>,
) => {
const normalizedSearch = params?.search?.trim() || null;
const typeValue = Array.isArray(params?.type)
? params.type.filter(Boolean).join(',')
: params?.type ?? null;
const statusValue = Array.isArray(params?.status)
? params.status.filter(Boolean).join(',')
: params?.status ?? null;
const originValue = Array.isArray(params?.origin)
? params.origin.filter(Boolean).join(',')
: params?.origin ?? null;
const paramsKeyPayload = {
limit: params?.limit ?? null,
offset: params?.offset ?? null,
search: normalizedSearch,
type: typeValue,
status: statusValue,
origin: originValue,
};
const paramsKey = JSON.stringify(paramsKeyPayload);
const queryParams: Record<string, string | number | undefined> = {
limit: params?.limit,
offset: params?.offset,
search: normalizedSearch ?? undefined,
type: typeValue ?? undefined,
status: statusValue ?? undefined,
origin: originValue ?? undefined,
};
return useQuery<AdminEventsResponse, AxiosError, AdminEventsResponse, AdminEventsQueryKey>(
['admin', 'events', paramsKey],
async () => {
const { data } = await request.get<AdminEventsResponse>('/admin.events', {
params: queryParams,
});
return data;
},
{
...defaultQueryOptions,
...options,
},
);
};
export const useAdminSystem = (
options?: QueryOptions<AdminSystemResponse, ['admin', 'system']>,
) => {
@ -930,6 +1055,18 @@ export const useAdminNodeSetRole = (
);
};
export const useAdminSetUserAdmin = (
options?: MutationOptions<{ ok: true; user: { id: number; is_admin: boolean } }, { user_id: number; is_admin: boolean }>,
) => {
return useMutation<{ ok: true; user: { id: number; is_admin: boolean } }, AxiosError, { user_id: number; is_admin: boolean }>(
async (payload) => {
const { data } = await request.post<{ ok: true; user: { id: number; is_admin: boolean } }>('/admin.users.setAdmin', payload);
return data;
},
options,
);
};
export const useAdminSyncLimits = () => {
const cacheSetLimits = useAdminCacheSetLimits();
const syncSetLimits = useAdminSyncSetLimits();