From 3b31c4e6cbcaac9c7d0dfc2c756b9ef511de213c Mon Sep 17 00:00:00 2001 From: unexpected Date: Wed, 15 Oct 2025 16:57:47 +0000 Subject: [PATCH] events & global sync. unstable --- src/app/router/index.tsx | 2 + src/pages/admin/components/CopyButton.tsx | 36 +-- src/pages/admin/config.ts | 2 + src/pages/admin/sections/Events.tsx | 307 ++++++++++++++++++++++ src/pages/admin/sections/Stars.tsx | 14 +- src/pages/admin/sections/System.tsx | 42 ++- src/pages/admin/sections/Uploads.tsx | 91 ++++++- src/pages/admin/sections/Users.tsx | 86 +++++- src/pages/admin/sections/index.ts | 1 + src/shared/services/admin/index.ts | 137 ++++++++++ 10 files changed, 691 insertions(+), 27 deletions(-) create mode 100644 src/pages/admin/sections/Events.tsx diff --git a/src/app/router/index.tsx b/src/app/router/index.tsx index 80a592c..5151273 100644 --- a/src/app/router/index.tsx +++ b/src/app/router/index.tsx @@ -9,6 +9,7 @@ import { AdminStoragePage, AdminUploadsPage, AdminUsersPage, + AdminEventsPage, AdminLicensesPage, AdminStarsPage, AdminSystemPage, @@ -50,6 +51,7 @@ const router = createBrowserRouter([ { path: "overview", element: }, { path: "storage", element: }, { path: "uploads", element: }, + { path: "events", element: }, { path: "users", element: }, { path: "licenses", element: }, { path: "stars", element: }, diff --git a/src/pages/admin/components/CopyButton.tsx b/src/pages/admin/components/CopyButton.tsx index 0e51d9c..bf435ec 100644 --- a/src/pages/admin/components/CopyButton.tsx +++ b/src/pages/admin/components/CopyButton.tsx @@ -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,25 +60,28 @@ export const CopyButton = ({ value, className, "aria-label": ariaLabel, successM ); }; diff --git a/src/pages/admin/config.ts b/src/pages/admin/config.ts index d7916b1..c7675bb 100644 --- a/src/pages/admin/config.ts +++ b/src/pages/admin/config.ts @@ -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" }, diff --git a/src/pages/admin/sections/Events.tsx b/src/pages/admin/sections/Events.tsx new file mode 100644 index 0000000..1e39470 --- /dev/null +++ b/src/pages/admin/sections/Events.tsx @@ -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 = { + content_uploaded: "Загрузка контента", + content_indexed: "Индексация контента", + stars_payment: "Платёж Stars", + node_registered: "Регистрация ноды", + user_role_changed: "Изменение ролей", +}; + +const EVENT_STATUS_TONES: Record = { + local: "neutral", + recorded: "neutral", + processing: "warn", + applied: "success", + failed: "danger", +}; + +const linkLabels: Record = { + admin_uploads: "К загрузкам", + content_view: "Просмотр контента", + admin_stars: "К платежам", + admin_user: "К пользователю", +}; + +export const AdminEventsPage = () => { + const { isAuthorized, handleRequestError } = useAdminContext(); + const [search, setSearch] = useState(""); + const [typeFilter, setTypeFilter] = useState(null); + const [statusFilter, setStatusFilter] = useState(null); + const [originFilter, setOriginFilter] = useState(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 ( + + {label} + + ); + } + const normalized = url.startsWith("/") ? url.slice(1) : url; + return ( + + ); + }; + + const events = eventsQuery.data?.items ?? []; + const total = eventsQuery.data?.total ?? 0; + + return ( +
eventsQuery.refetch()} + disabled={eventsQuery.isFetching} + > + {eventsQuery.isFetching ? "Обновляем…" : "Обновить"} + + } + > +
+
+

Всего событий

+

{numberFormatter.format(total)}

+

На странице {numberFormatter.format(events.length)}

+
+
+

Типы

+

{Object.keys(availableFilters.types).length}

+

Фильтр по типу помогает сузить список

+
+
+

Статусы

+

{Object.keys(availableFilters.statuses).length}

+

Например, только требующие действий

+
+
+

Источники

+

{Object.keys(availableFilters.origins).length}

+

Отслеживайте trusted-нод

+
+
+ +
+
+ + 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" + /> +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+ + {eventsQuery.isLoading && !eventsQuery.data ? ( +
+ Загружаем события… +
+ ) : null} + +
+ {events.length === 0 ? ( +
+ События не найдены. +
+ ) : ( + 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 ( +
+
+
+
+ #{numberFormatter.format(event.seq)} + {typeLabel} + {event.status} +
+
+ UID: {event.uid} + +
+
+
+
Создано: {formatDate(event.created_at)}
+
Получено: {formatDate(event.received_at)}
+
Применено: {formatDate(event.applied_at)}
+
+
+
+
+ Источник +
+ {event.origin_public_key ?? "—"} + {event.origin_public_key ? ( + + ) : null} +
+
{event.origin_host ?? "—"}
+
+
+ Ссылки + {linkEntries.length === 0 ? ( +
Нет прямых ссылок
+ ) : ( +
+ {linkEntries.map(([key, url]) => renderLink(key, url))} +
+ )} +
+
+
+ Показать payload +
+                    {JSON.stringify(event.payload ?? {}, null, 2)}
+                  
+
+
+ ); + }) + )} +
+ +
+ setPage(next)} + /> +
+
+ ); +}; diff --git a/src/pages/admin/sections/Stars.tsx b/src/pages/admin/sections/Stars.tsx index 535916a..49421c1 100644 --- a/src/pages/admin/sections/Stars.tsx +++ b/src/pages/admin/sections/Stars.tsx @@ -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]); diff --git a/src/pages/admin/sections/System.tsx b/src/pages/admin/sections/System.tsx index 56397e0..828ec42 100644 --- a/src/pages/admin/sections/System.tsx +++ b/src/pages/admin/sections/System.tsx @@ -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 (
@@ -35,6 +36,43 @@ export const AdminSystemPage = () => { ))} +
+

Telegram-боты

+ {bots.length === 0 ? ( +

Боты не настроены.

+ ) : ( + + )} +

Задачи блокчейна

diff --git a/src/pages/admin/sections/Uploads.tsx b/src/pages/admin/sections/Uploads.tsx index 25a2722..91e487c 100644 --- a/src/pages/admin/sections/Uploads.tsx +++ b/src/pages/admin/sections/Uploads.tsx @@ -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 (
@@ -235,6 +240,79 @@ const UploadCard = ({ decoration }: { decoration: UploadDecoration }) => {
+ {distributionNodes.length > 0 ? ( +
+

Синхронизация

+
    + {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 ( +
  • +
    +
    +
    + {nodeTitle} + {node.is_local ? "локально" : "удаленно"} + {node.role ? {node.role} : null} +
    +
    + {sizeLabel ?

    Размер: {sizeLabel}

    : null} + {updatedLabel ?

    Обновлено: {updatedLabel}

    : null} + {lastSeenLabel && !node.is_local ?

    Нода активна: {lastSeenLabel}

    : null} + {node.version ?

    Версия: {node.version}

    : null} +
    +
    +
    + {node.links.web_view ? ( + + Web + + ) : null} + {node.links.api_view ? ( + <> + + API + + + копировать API + + + ) : null} + {node.links.gateway_view ? ( + + Gateway + + ) : null} +
    +
    +
  • + ); + })} +
+
+ ) : null} +

История загрузки

    @@ -397,8 +475,17 @@ const UploadCard = ({ decoration }: { decoration: UploadDecoration }) => { export const AdminUploadsPage = () => { const { isAuthorized, handleRequestError } = useAdminContext(); + const location = useLocation(); const [uploadsFilter, setUploadsFilter] = useState("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; diff --git a/src/pages/admin/sections/Users.tsx b/src/pages/admin/sections/Users.tsx index cc9fb5e..ac46988 100644 --- a/src/pages/admin/sections/Users.tsx +++ b/src/pages/admin/sections/Users.tsx @@ -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(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 ?? "")}
) : null} +
+ + {user.is_admin ? "Администратор" : "Пользователь"} + + +
@@ -291,6 +347,26 @@ export const AdminUsersPage = () => { {numberFormatter.format(user.licenses.active)}
+
+ Роль +
+ + {user.is_admin ? "Администратор" : "Пользователь"} + + +
+
Последняя активность {user.ip_activity.last ? ( diff --git a/src/pages/admin/sections/index.ts b/src/pages/admin/sections/index.ts index 0e1c0e1..806877d 100644 --- a/src/pages/admin/sections/index.ts +++ b/src/pages/admin/sections/index.ts @@ -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"; diff --git a/src/shared/services/admin/index.ts b/src/shared/services/admin/index.ts index b94b3b1..fe7fb2d 100644 --- a/src/shared/services/admin/index.ts +++ b/src/shared/services/admin/index.ts @@ -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; + links: Record; +}; + +export type AdminEventsResponse = { + total: number; + limit: number; + offset: number; + filters?: Record; + items: AdminEventItem[]; + available_filters: { + types: Record; + statuses: Record; + origins: Record; + }; +}; + +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, +) => { + 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 = { + limit: params?.limit, + offset: params?.offset, + search: normalizedSearch ?? undefined, + type: typeValue ?? undefined, + status: statusValue ?? undefined, + origin: originValue ?? undefined, + }; + + return useQuery( + ['admin', 'events', paramsKey], + async () => { + const { data } = await request.get('/admin.events', { + params: queryParams, + }); + return data; + }, + { + ...defaultQueryOptions, + ...options, + }, + ); +}; + export const useAdminSystem = ( options?: QueryOptions, ) => { @@ -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();