diff --git a/src/app/router/index.tsx b/src/app/router/index.tsx index f0cbc5c..80a592c 100644 --- a/src/app/router/index.tsx +++ b/src/app/router/index.tsx @@ -3,7 +3,19 @@ import { createBrowserRouter, RouterProvider } from "react-router-dom"; import { Routes } from "~/app/router/constants"; import { RootPage } from "~/pages/root"; import { ViewContentPage } from "~/pages/view-content"; -import { AdminPage } from "~/pages/admin"; +import { AdminPage, AdminIndexRedirect } from "~/pages/admin"; +import { + AdminOverviewPage, + AdminStoragePage, + AdminUploadsPage, + AdminUsersPage, + AdminLicensesPage, + AdminStarsPage, + AdminSystemPage, + AdminBlockchainPage, + AdminNodesPage, + AdminStatusPage, +} from "~/pages/admin/sections"; import { ProtectedLayout } from "./protected-layout"; const router = createBrowserRouter([ @@ -30,7 +42,23 @@ const router = createBrowserRouter([ }, ], }, - { path: Routes.Admin, element: }, + { + path: Routes.Admin, + element: , + children: [ + { index: true, element: }, + { path: "overview", element: }, + { path: "storage", element: }, + { path: "uploads", element: }, + { path: "users", element: }, + { path: "licenses", element: }, + { path: "stars", element: }, + { path: "system", element: }, + { path: "blockchain", element: }, + { path: "nodes", element: }, + { path: "status", element: }, + ], + }, ]); export const AppRouter = () => { diff --git a/src/pages/admin/components/Badge.tsx b/src/pages/admin/components/Badge.tsx new file mode 100644 index 0000000..37921c6 --- /dev/null +++ b/src/pages/admin/components/Badge.tsx @@ -0,0 +1,24 @@ +import type { ReactNode } from "react"; +import clsx from "clsx"; + +type BadgeTone = "neutral" | "success" | "danger" | "warn"; + +const toneClassMap: Record = { + neutral: "bg-slate-800 text-slate-200", + success: "bg-emerald-900/70 text-emerald-200", + danger: "bg-rose-900/60 text-rose-100", + warn: "bg-amber-900/60 text-amber-100", +}; + +export const Badge = ({ children, tone = "neutral" }: { children: ReactNode; tone?: BadgeTone }) => { + return ( + + {children} + + ); +}; diff --git a/src/pages/admin/components/InfoRow.tsx b/src/pages/admin/components/InfoRow.tsx new file mode 100644 index 0000000..e041dd9 --- /dev/null +++ b/src/pages/admin/components/InfoRow.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from "react"; + +export const InfoRow = ({ label, children }: { label: string; children: ReactNode }) => { + return ( +
+ {label} + {children} +
+ ); +}; diff --git a/src/pages/admin/components/PaginationControls.tsx b/src/pages/admin/components/PaginationControls.tsx new file mode 100644 index 0000000..686e5d9 --- /dev/null +++ b/src/pages/admin/components/PaginationControls.tsx @@ -0,0 +1,49 @@ +type PaginationControlsProps = { + total: number; + limit: number; + page: number; + onPageChange: (next: number) => void; +}; + +const numberFormatter = new Intl.NumberFormat("ru-RU"); + +export const PaginationControls = ({ total, limit, page, onPageChange }: PaginationControlsProps) => { + const safeLimit = Math.max(limit, 1); + const totalPages = Math.max(1, Math.ceil(Math.max(total, 0) / safeLimit)); + const clampedPage = Math.min(Math.max(page, 0), totalPages - 1); + const startIndex = total === 0 ? 0 : clampedPage * safeLimit + 1; + const endIndex = total === 0 ? 0 : Math.min(total, (clampedPage + 1) * safeLimit); + const canGoPrev = clampedPage > 0; + const canGoNext = clampedPage + 1 < totalPages; + + return ( +
+
+ {total === 0 + ? "Нет записей" + : `Показаны ${numberFormatter.format(startIndex)}–${numberFormatter.format(endIndex)} из ${numberFormatter.format(total)}`} +
+
+ + + Стр. {numberFormatter.format(clampedPage + 1)} из {numberFormatter.format(totalPages)} + + +
+
+ ); +}; diff --git a/src/pages/admin/components/Section.tsx b/src/pages/admin/components/Section.tsx new file mode 100644 index 0000000..5bc62e5 --- /dev/null +++ b/src/pages/admin/components/Section.tsx @@ -0,0 +1,32 @@ +import type { ReactNode } from "react"; +import clsx from "clsx"; + +type SectionProps = { + id?: string; + title: string; + description?: string; + actions?: ReactNode; + children: ReactNode; + className?: string; +}; + +export const Section = ({ id, title, description, actions, children, className }: SectionProps) => { + return ( +
+
+
+

{title}

+ {description ?

{description}

: null} +
+ {actions ?
{actions}
: null} +
+
{children}
+
+ ); +}; diff --git a/src/pages/admin/components/index.ts b/src/pages/admin/components/index.ts new file mode 100644 index 0000000..f0b33f1 --- /dev/null +++ b/src/pages/admin/components/index.ts @@ -0,0 +1,4 @@ +export * from "./Section"; +export * from "./Badge"; +export * from "./PaginationControls"; +export * from "./InfoRow"; diff --git a/src/pages/admin/config.ts b/src/pages/admin/config.ts new file mode 100644 index 0000000..d7916b1 --- /dev/null +++ b/src/pages/admin/config.ts @@ -0,0 +1,36 @@ +import type { ReactNode } from "react"; + +export type AdminSectionId = + | "overview" + | "storage" + | "uploads" + | "users" + | "licenses" + | "stars" + | "system" + | "blockchain" + | "nodes" + | "status"; + +export type AdminSection = { + id: AdminSectionId; + label: string; + description: string; + path: string; + icon?: ReactNode; +}; + +export const ADMIN_SECTIONS: AdminSection[] = [ + { id: "overview", label: "Обзор", description: "Краткий срез состояния узла, окружения и служб", path: "overview" }, + { id: "storage", label: "Хранилище", description: "Загруженность диска и директории контента", path: "storage" }, + { id: "uploads", label: "Загрузки", description: "Отслеживание статуса загрузок и деривативов", path: "uploads" }, + { id: "users", label: "Пользователи", description: "Мониторинг пользователей, кошельков и активности", path: "users" }, + { id: "licenses", label: "Лицензии", description: "Статус лицензий и фильтрация по типам", path: "licenses" }, + { id: "stars", label: "Платежи Stars", description: "Управление счетами и анализ платежей", path: "stars" }, + { id: "system", label: "Система", description: "Настройки окружения и состояние сервисов", path: "system" }, + { id: "blockchain", label: "Блокчейн", description: "История задач и метрики блокчейн-интеграции", path: "blockchain" }, + { id: "nodes", label: "Ноды", description: "Роли, версии и последнее появление узлов", path: "nodes" }, + { id: "status", label: "Статус & лимиты", description: "IPFS, очереди и лимиты синхронизации", path: "status" }, +]; + +export const DEFAULT_ADMIN_SECTION = ADMIN_SECTIONS[0]; diff --git a/src/pages/admin/context.tsx b/src/pages/admin/context.tsx new file mode 100644 index 0000000..fa4075d --- /dev/null +++ b/src/pages/admin/context.tsx @@ -0,0 +1,13 @@ +import { createContext, useContext } from "react"; + +import type { AdminContextValue } from "./types"; + +export const AdminContext = createContext(undefined); + +export const useAdminContext = () => { + const ctx = useContext(AdminContext); + if (!ctx) { + throw new Error("useAdminContext must be used within "); + } + return ctx; +}; diff --git a/src/pages/admin/index.tsx b/src/pages/admin/index.tsx index 491c442..3d637f5 100644 --- a/src/pages/admin/index.tsx +++ b/src/pages/admin/index.tsx @@ -1,798 +1,198 @@ -import { ChangeEvent, ReactNode, useEffect, useMemo, useState } from "react"; -import { useForm } from "react-hook-form"; -import { useQueryClient } from "react-query"; +import { ReactNode, useCallback, useEffect, useMemo, useState } from "react"; +import { NavLink, Navigate, Outlet, useLocation } from "react-router-dom"; import clsx from "clsx"; +import { useQueryClient } from "react-query"; +import { Routes } from "~/app/router/constants"; import { - isUnauthorizedError, - useAdminBlockchain, - useAdminCacheCleanup, - useAdminCacheSetLimits, useAdminLogin, useAdminLogout, - useAdminNodeSetRole, - useAdminNodes, useAdminOverview, - useAdminStatus, - useAdminStorage, - useAdminStars, - useAdminSyncSetLimits, - useAdminSystem, - useAdminUploads, - useAdminLicenses, - useAdminUsers, } from "~/shared/services/admin"; -import type { AdminUploadsContent, AdminUploadsContentFlags } from "~/shared/services/admin"; import { clearAdminAuth, getAdminAuthSnapshot } from "~/shared/libs/admin-auth"; +import { isUnauthorizedError } from "~/shared/services/admin"; +import { ADMIN_SECTIONS, DEFAULT_ADMIN_SECTION, type AdminSection } from "./config"; +import { AdminContext } from "./context"; +import type { AuthState, FlashMessage } from "./types"; -const numberFormatter = new Intl.NumberFormat("ru-RU"); -const dateTimeFormatter = new Intl.DateTimeFormat("ru-RU", { - dateStyle: "short", - timeStyle: "medium", -}); +const initialAuthState: AuthState = getAdminAuthSnapshot().token ? "checking" : "unauthorized"; -const formatBytes = (input?: number | null) => { - if (!input) { - return "0 B"; +const resolveErrorMessage = (error: unknown) => { + if (error && typeof error === "object" && "message" in error && typeof (error as any).message === "string") { + return (error as any).message as string; } - const units = ["B", "KB", "MB", "GB", "TB", "PB"] as const; - let value = input; - let unitIndex = 0; - while (value >= 1024 && unitIndex < units.length - 1) { - value /= 1024; - unitIndex += 1; - } - return `${value.toFixed(value >= 10 || value < 0.1 ? 0 : 1)} ${units[unitIndex]}`; + return "Неизвестная ошибка"; }; -const formatStars = (input?: number | null) => { - const value = Number(input ?? 0); - return `${numberFormatter.format(value)} ⭑`; -}; - -const formatDate = (iso?: string | null) => { - if (!iso) { - return "—"; +const AdminFlash = ({ flash, onClose }: { flash: FlashMessage | null; onClose: () => void }) => { + if (!flash) { + return null; } - const date = new Date(iso); - if (Number.isNaN(date.getTime())) { - return iso; - } - return dateTimeFormatter.format(date); -}; - -const formatUnknown = (value: unknown): string => { - if (value === null || value === undefined) { - return "—"; - } - if (typeof value === "string") { - return value || "—"; - } - if (typeof value === "number" || typeof value === "bigint") { - return numberFormatter.format(Number(value)); - } - if (typeof value === "boolean") { - return value ? "Да" : "Нет"; - } - if (value instanceof Date) { - return formatDate(value.toISOString()); - } - try { - return JSON.stringify(value); - } catch (error) { - return String(value); - } -}; - -const Section = ({ - id, - title, - description, - children, - actions, -}: { - id: string; - title: string; - description?: string; - children: ReactNode; - actions?: ReactNode; -}) => { + const tone = + flash.type === "success" + ? "border-emerald-500/60 bg-emerald-500/10 text-emerald-100" + : flash.type === "error" + ? "border-rose-500/60 bg-rose-500/10 text-rose-100" + : "border-sky-500/60 bg-sky-500/10 text-sky-100"; return ( -
-
-
-

{title}

- {description ? ( -

{description}

- ) : null} -
- {actions ?
{actions}
: null} -
-
{children}
-
- ); -}; - -type BadgeTone = "neutral" | "success" | "danger" | "warn"; - -const Badge = ({ children, tone = "neutral" }: { children: ReactNode; tone?: BadgeTone }) => { - const toneMap: Record = { - neutral: "bg-slate-800 text-slate-200", - success: "bg-emerald-900/70 text-emerald-200", - danger: "bg-rose-900/60 text-rose-100", - warn: "bg-amber-900/60 text-amber-100", - }; - return ( - - {children} - - ); -}; - -const PaginationControls = ({ - total, - limit, - page, - onPageChange, -}: { - total: number; - limit: number; - page: number; - onPageChange: (next: number) => void; -}) => { - const safeLimit = Math.max(limit, 1); - const totalPages = Math.max(1, Math.ceil(Math.max(total, 0) / safeLimit)); - const clampedPage = Math.min(Math.max(page, 0), totalPages - 1); - const startIndex = total === 0 ? 0 : clampedPage * safeLimit + 1; - const endIndex = total === 0 ? 0 : Math.min(total, (clampedPage + 1) * safeLimit); - const canGoPrev = clampedPage > 0; - const canGoNext = clampedPage + 1 < totalPages; - - return ( -
-
- {total === 0 - ? 'Нет записей' - : `Показаны ${numberFormatter.format(startIndex)}–${numberFormatter.format(endIndex)} из ${numberFormatter.format(total)}`} -
-
- - Стр. {numberFormatter.format(clampedPage + 1)} из {numberFormatter.format(totalPages)} - -
+
+ {flash.message} +
); }; -type FlashMessage = { - type: "success" | "error" | "info"; - message: string; -}; - -type AuthState = "checking" | "authorized" | "unauthorized"; - -const ADMIN_SECTIONS = [ - { id: "overview", label: "Обзор" }, - { id: "storage", label: "Хранилище" }, - { id: "uploads", label: "Загрузки" }, - { id: "users", label: "Пользователи" }, - { id: "licenses", label: "Лицензии" }, - { id: "stars", label: "Платежи Stars" }, - { id: "system", label: "Система" }, - { id: "blockchain", label: "Блокчейн" }, - { id: "nodes", label: "Ноды" }, - { id: "status", label: "Статус & лимиты" }, -] as const; - -type CacheLimitsFormValues = { - max_gb: number; - ttl_days: number; -}; - -type CacheFitFormValues = { - max_gb: number; -}; - -type SyncLimitsFormValues = { - max_concurrent_pins: number; - disk_low_watermark_pct: number; -}; - -type UploadCategory = "issues" | "processing" | "ready" | "unindexed"; -type UploadFilter = "all" | UploadCategory; - -type UploadDecoration = { - item: AdminUploadsContent; - categories: Set; - searchText: string; - hasIssue: boolean; - isReady: boolean; - isProcessing: boolean; - isUnindexed: boolean; - flags: AdminUploadsContentFlags | null; -}; - -const initialAuthState: AuthState = getAdminAuthSnapshot().token ? "checking" : "unauthorized"; - -type UsersSectionProps = { - query: ReturnType; - search: string; - onSearchChange: (value: string) => void; - page: number; - limit: number; - onPageChange: (next: number) => void; - onRefresh: () => void; -}; - -const UsersSection = ({ query, search, onSearchChange, page, limit, onPageChange, onRefresh }: UsersSectionProps) => { - if (query.isLoading && !query.data) { - return
Загрузка…
; - } - - if (!query.data) { - return ( -
-

Выберите вкладку «Пользователи», чтобы загрузить данные.

-
- ); - } - - const { items, summary, total } = query.data; - const summaryCards = [ - { label: 'Всего пользователей', value: numberFormatter.format(total), helper: `На странице: ${numberFormatter.format(summary.users_returned)}` }, - { label: 'Кошельки (активные/всего)', value: `${numberFormatter.format(summary.wallets_active)} / ${numberFormatter.format(summary.wallets_total)}` }, - { label: 'Оплачено Stars', value: formatStars(summary.stars_amount_paid), helper: `${numberFormatter.format(summary.stars_paid)} платежей` }, - { label: 'Неоплачено', value: formatStars(summary.stars_amount_unpaid), helper: `${numberFormatter.format(summary.stars_unpaid)} счетов` }, - ]; - +const AdminLoginForm = ({ + token, + onTokenChange, + onSubmit, + isSubmitting, +}: { + token: string; + onTokenChange: (value: string) => void; + onSubmit: () => void; + isSubmitting: boolean; +}) => { return ( -
- Обновить - - } - > -
- {summaryCards.map((card) => ( -
-

{card.label}

-

{card.value}

- {card.helper ?

{card.helper}

: null} -
- ))} -
- -
-
- +
+

Админ-панель узла

+

+ Введите секретный токен ADMIN_API_TOKEN, чтобы получить доступ к мониторингу и управлению. +

+
{ + event.preventDefault(); + onSubmit(); + }} + > +
-
+ +
+ + Cookie max-age {Math.round(172800 / 3600)} ч +
-
- -
- - - - - - - - - - - - - {items.length === 0 ? ( - - - - ) : ( - items.map((user) => ( - - - - - - - - - )) - )} - -
ПользовательКошелькиStarsЛицензииАктивностьДата
Нет пользователей, удовлетворяющих условиям.
-
{user.username ? `@${user.username}` : 'Без username'}
-
ID {numberFormatter.format(user.id)} · TG {numberFormatter.format(user.telegram_id)}
- {(user.first_name || user.last_name) ? (
{(user.first_name ?? '')} {(user.last_name ?? '')}
) : null} -
-
Активных {numberFormatter.format(user.wallets.active_count)} / всего {numberFormatter.format(user.wallets.total_count)}
-
{user.wallets.primary_address ?? '—'}
-
-
{formatStars(user.stars.amount_total)}
-
Оплачено: {formatStars(user.stars.amount_paid)}
-
Неоплачено: {formatStars(user.stars.amount_unpaid)}
-
-
Всего {numberFormatter.format(user.licenses.total)}
-
Активных {numberFormatter.format(user.licenses.active)}
-
- {user.ip_activity.last ? ( -
{user.ip_activity.last.ip ?? '—'} · {formatDate(user.ip_activity.last.seen_at)}
- ) : ( -
Нет данных
- )} -
-
Создан: {formatDate(user.created_at)}
-
Последний вход: {formatDate(user.last_use)}
-
-
- -
- -
-
+ +
); }; -type LicensesSectionProps = { - query: ReturnType; - search: string; - onSearchChange: (value: string) => void; - statusFilter: string; - onStatusChange: (value: string) => void; - typeFilter: string; - onTypeChange: (value: string) => void; - licenseTypeFilter: string; - onLicenseTypeChange: (value: string) => void; - page: number; - limit: number; - onPageChange: (next: number) => void; - onRefresh: () => void; +const MobileNav = ({ sections, currentPath }: { sections: AdminSection[]; currentPath: string }) => ( + +); + +const DesktopNav = ({ + sections, + currentPath, +}: { + sections: AdminSection[]; + currentPath: string; +}) => { + return ( + + ); }; -const LicensesSection = ({ - query, - search, - onSearchChange, - statusFilter, - onStatusChange, - typeFilter, - onTypeChange, - licenseTypeFilter, - onLicenseTypeChange, - page, - limit, - onPageChange, - onRefresh, -}: LicensesSectionProps) => { - if (query.isLoading && !query.data) { - return
Загрузка…
; - } - - if (!query.data) { - return ( -
-

Выберите вкладку «Лицензии», чтобы загрузить данные.

-
- ); - } - - const { items, counts, total } = query.data; - const statusOptions = Object.keys(counts.status || {}); - const typeOptions = Object.keys(counts.type || {}); - const kindOptions = Object.keys(counts.license_type || {}); +const AdminLayoutFrame = ({ children, flash, onFlashClose }: { children: ReactNode; flash: FlashMessage | null; onFlashClose: () => void }) => { + const location = useLocation(); + const currentPath = location.pathname; return ( -
- Обновить - - } - > -
-
-
- - onSearchChange(event.target.value)} - placeholder="Адрес, владелец, hash" - 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 md:w-64" - /> +
+
+
+
+

MY Admin

+

Мониторинг и управление узлом

-
- - -
-
- - -
-
- - +
+
-
- +
+
+ +
+ +
+ + {children} +
-
- -
- - - - - - - - - - - - {items.length === 0 ? ( - - - - ) : ( - items.map((license) => ( - - - - - - - - )) - )} - -
ЛицензияOn-chainПользовательКонтентДата
Ничего не найдено.
-
#{numberFormatter.format(license.id)}
-
{license.type ?? '—'} · {license.status ?? '—'}
-
license_type: {license.license_type ?? '—'}
-
- {license.onchain_address ? ( - - {license.onchain_address} - - ) : ( -
- )} -
Владелец: {license.owner_address ?? '—'}
-
- {license.user ? ( -
ID {numberFormatter.format(license.user.id)} · TG {numberFormatter.format(license.user.telegram_id)}
- ) : ( -
- )} -
- {license.content ? ( - <> -
{license.content.title}
-
{license.content.hash}
- - ) : ( -
- )} -
-
Создано: {formatDate(license.created_at)}
-
Обновлено: {formatDate(license.updated_at)}
-
-
- -
- -
-
+ + ); }; -type StarsSectionProps = { - query: ReturnType; - search: string; - onSearchChange: (value: string) => void; - paidFilter: 'all' | 'paid' | 'unpaid'; - onPaidChange: (value: 'all' | 'paid' | 'unpaid') => void; - typeFilter: string; - onTypeChange: (value: string) => void; - page: number; - limit: number; - onPageChange: (next: number) => void; - onRefresh: () => void; -}; - -const StarsSection = ({ - query, - search, - onSearchChange, - paidFilter, - onPaidChange, - typeFilter, - onTypeChange, - page, - limit, - onPageChange, - onRefresh, -}: StarsSectionProps) => { - if (query.isLoading && !query.data) { - return
Загрузка…
; - } - - if (!query.data) { - return ( -
-

Выберите вкладку «Платежи Stars», чтобы загрузить данные.

-
- ); - } - - const { items, stats, total } = query.data; - const typeOptions = Object.keys(stats.by_type || {}); - - const statCards = [ - { label: 'Всего платежей', value: numberFormatter.format(stats.total) }, - { label: 'Оплачено', value: `${numberFormatter.format(stats.paid)} · ${formatStars(stats.amount_paid)}` }, - { label: 'Неоплачено', value: `${numberFormatter.format(stats.unpaid)} · ${formatStars(stats.amount_unpaid)}` }, - ]; - - return ( -
- Обновить - - } - > -
- {statCards.map((card) => ( -
-

{card.label}

-

{card.value}

-
- ))} -
- -
-
-
- - onSearchChange(event.target.value)} - placeholder="ID, ссылка, контент" - 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 md:w-64" - /> -
-
- - -
-
- - -
-
-
- -
-
- -
- - - - - - - - - - - - {items.length === 0 ? ( - - - - ) : ( - items.map((invoice) => ( - - - - - - - - )) - )} - -
ИнвойсСуммаПользовательКонтентСоздан
Платежей не найдено.
-
#{numberFormatter.format(invoice.id)} · {invoice.type ?? '—'}
-
{invoice.external_id}
- {invoice.invoice_url ? ( - - Открыть счёт - - ) : null} -
-
{formatStars(invoice.amount)}
- {invoice.status} -
- {invoice.user ? ( -
ID {numberFormatter.format(invoice.user.id)} · TG {numberFormatter.format(invoice.user.telegram_id)}
- ) : ( -
- )} -
- {invoice.content ? ( - <> -
{invoice.content.title}
-
{invoice.content.hash}
- - ) : ( -
- )} -
{formatDate(invoice.created_at)}
-
- -
- -
-
- ); -}; - - -export const AdminPage = () => { +export const AdminShell = () => { const queryClient = useQueryClient(); const [authState, setAuthState] = useState(initialAuthState); - const [activeSection, setActiveSection] = useState<(typeof ADMIN_SECTIONS)[number]["id"]>("overview"); const [token, setToken] = useState(""); const [flash, setFlash] = useState(null); - const [uploadsFilter, setUploadsFilter] = useState("all"); - const [uploadsSearch, setUploadsSearch] = useState(""); - const [usersPage, setUsersPage] = useState(0); - const [usersSearch, setUsersSearch] = useState(""); - const [licensesPage, setLicensesPage] = useState(0); - const [licensesSearch, setLicensesSearch] = useState(""); - const [licensesStatusFilter, setLicensesStatusFilter] = useState("all"); - const [licensesTypeFilter, setLicensesTypeFilter] = useState("all"); - const [licensesLicenseTypeFilter, setLicensesLicenseTypeFilter] = useState("all"); - const [starsPage, setStarsPage] = useState(0); - const [starsSearch, setStarsSearch] = useState(""); - const [starsPaidFilter, setStarsPaidFilter] = useState<'all' | 'paid' | 'unpaid'>('all'); - const [starsTypeFilter, setStarsTypeFilter] = useState("all"); - - const normalizedUploadsSearch = uploadsSearch.trim(); - const normalizedUsersSearch = usersSearch.trim(); - const normalizedLicensesSearch = licensesSearch.trim(); - const normalizedStarsSearch = starsSearch.trim(); - const isUploadsFilterActive = uploadsFilter !== "all"; - const hasUploadsSearch = normalizedUploadsSearch.length > 0; - const uploadsScanLimit = isUploadsFilterActive || hasUploadsSearch ? 200 : 100; useEffect(() => { if (!flash) { @@ -810,1615 +210,141 @@ export const AdminPage = () => { } }, [authState, queryClient]); - useEffect(() => { - setUsersPage(0); - }, [normalizedUsersSearch]); + const pushFlash = useCallback((message: FlashMessage) => { + setFlash(message); + }, []); - useEffect(() => { - setLicensesPage(0); - }, [normalizedLicensesSearch, licensesStatusFilter, licensesTypeFilter, licensesLicenseTypeFilter]); + const clearFlash = useCallback(() => { + setFlash(null); + }, []); - useEffect(() => { - setStarsPage(0); - }, [normalizedStarsSearch, starsPaidFilter, starsTypeFilter]); - - const overviewQuery = useAdminOverview({ - enabled: authState !== "unauthorized", - retry: false, - onSuccess: () => { - setAuthState((prev) => (prev === "authorized" ? prev : "authorized")); - }, - onError: (error) => { + const handleRequestError = useCallback( + (error: unknown, fallbackMessage: string) => { if (isUnauthorizedError(error)) { clearAdminAuth(); setAuthState("unauthorized"); - setFlash({ type: "info", message: "Введите админ-токен, чтобы продолжить" }); + pushFlash({ type: "info", message: "Сессия истекла. Введите админ-токен заново." }); return; } - setFlash({ type: "error", message: `Не удалось загрузить обзор: ${error.message}` }); + const tail = resolveErrorMessage(error); + pushFlash({ + type: "error", + message: `${fallbackMessage}: ${tail}`, + }); }, - }); + [pushFlash], + ); - const isDataEnabled = authState === "authorized"; + const invalidateAll = useCallback(async () => { + await queryClient.invalidateQueries({ + predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "admin", + }); + }, [queryClient]); - const storageQuery = useAdminStorage({ - enabled: isDataEnabled, - refetchInterval: 60_000, - }); - const uploadsQuery = useAdminUploads( - { - filter: isUploadsFilterActive ? uploadsFilter : undefined, - search: hasUploadsSearch ? normalizedUploadsSearch : undefined, - limit: 40, - scan: uploadsScanLimit, + useAdminOverview({ + enabled: authState === "checking", + retry: false, + onSuccess: () => { + setAuthState("authorized"); }, - { - enabled: isDataEnabled, - refetchInterval: 30_000, - keepPreviousData: true, + onError: (error) => { + handleRequestError(error, "Не удалось проверить админ-сессию"); }, - ); - const usersLimit = 50; - const usersQuery = useAdminUsers( - { - limit: usersLimit, - offset: usersPage * usersLimit, - search: normalizedUsersSearch || undefined, - }, - { - enabled: isDataEnabled && activeSection === "users", - keepPreviousData: true, - refetchInterval: 60_000, - }, - ); - const licensesLimit = 50; - const licensesQuery = useAdminLicenses( - { - limit: licensesLimit, - offset: licensesPage * licensesLimit, - search: normalizedLicensesSearch || undefined, - type: licensesTypeFilter !== "all" ? licensesTypeFilter : undefined, - status: licensesStatusFilter !== "all" ? licensesStatusFilter : undefined, - license_type: licensesLicenseTypeFilter !== "all" ? licensesLicenseTypeFilter : undefined, - }, - { - enabled: isDataEnabled && activeSection === "licenses", - keepPreviousData: true, - refetchInterval: 60_000, - }, - ); - const starsLimit = 50; - const starsQuery = useAdminStars( - { - limit: starsLimit, - offset: starsPage * starsLimit, - search: normalizedStarsSearch || undefined, - type: starsTypeFilter !== "all" ? starsTypeFilter : undefined, - paid: - starsPaidFilter === 'all' - ? undefined - : starsPaidFilter === 'paid' - ? true - : false, - }, - { - enabled: isDataEnabled && activeSection === "stars", - keepPreviousData: true, - refetchInterval: 60_000, - }, - ); - const systemQuery = useAdminSystem({ - enabled: isDataEnabled, - refetchInterval: 60_000, - }); - const blockchainQuery = useAdminBlockchain({ - enabled: isDataEnabled, - refetchInterval: 30_000, - }); - const nodesQuery = useAdminNodes({ - enabled: isDataEnabled, - refetchInterval: 60_000, - }); - const statusQuery = useAdminStatus({ - enabled: isDataEnabled, - refetchInterval: 30_000, }); const loginMutation = useAdminLogin({ onSuccess: async () => { setAuthState("authorized"); - setFlash({ type: "success", message: "Админ-сессия активирована" }); setToken(""); - await queryClient.invalidateQueries({ - predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "admin", - }); + pushFlash({ type: "success", message: "Админ-сессия активирована" }); + await invalidateAll(); }, onError: (error) => { if (isUnauthorizedError(error)) { clearAdminAuth(); - setFlash({ type: "error", message: "Неверный токен" }); + pushFlash({ type: "error", message: "Неверный токен" }); return; } - setFlash({ type: "error", message: `Ошибка входа: ${error.message}` }); + handleRequestError(error, "Ошибка входа"); }, }); const logoutMutation = useAdminLogout({ onSuccess: async () => { setAuthState("unauthorized"); - setFlash({ type: "info", message: "Сессия завершена" }); - await queryClient.invalidateQueries({ - predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "admin", - }); + pushFlash({ type: "info", message: "Сессия завершена" }); + await invalidateAll(); }, onError: (error) => { - setFlash({ type: "error", message: `Ошибка выхода: ${error.message}` }); + handleRequestError(error, "Ошибка выхода"); }, }); - const cacheLimitsMutation = useAdminCacheSetLimits({ - onSuccess: async () => { - setFlash({ type: "success", message: "Лимиты кэша обновлены" }); - await statusQuery.refetch(); - }, - onError: (error) => { - setFlash({ type: "error", message: `Не удалось сохранить лимиты кэша: ${error.message}` }); - }, - }); + const contextValue = useMemo( + () => ({ + authState, + isAuthorized: authState === "authorized", + pushFlash, + clearFlash, + handleRequestError, + invalidateAll, + }), + [authState, pushFlash, clearFlash, handleRequestError, invalidateAll], + ); - const cacheCleanupMutation = useAdminCacheCleanup({ - onSuccess: async (data) => { - const removed = typeof data.removed === "number" ? `Удалено файлов: ${data.removed}` : ""; - setFlash({ type: "success", message: `Очистка кэша выполнена. ${removed}`.trim() }); - await statusQuery.refetch(); - }, - onError: (error) => { - setFlash({ type: "error", message: `Ошибка очистки кэша: ${error.message}` }); - }, - }); - - const syncLimitsMutation = useAdminSyncSetLimits({ - onSuccess: async () => { - setFlash({ type: "success", message: "Параметры синхронизации обновлены" }); - await statusQuery.refetch(); - }, - onError: (error) => { - setFlash({ type: "error", message: `Не удалось обновить лимиты синхронизации: ${error.message}` }); - }, - }); - - const nodeRoleMutation = useAdminNodeSetRole({ - onSuccess: async ({ node }) => { - setFlash({ - type: "success", - message: `Роль узла ${node.public_key ?? node.ip ?? ""} обновлена до ${node.role}`, - }); - await nodesQuery.refetch(); - }, - onError: (error) => { - setFlash({ type: "error", message: `Не удалось обновить роль узла: ${error.message}` }); - }, - }); - - const cacheLimitsForm = useForm({ - values: { - max_gb: statusQuery.data?.limits.DERIVATIVE_CACHE_MAX_GB ?? 50, - ttl_days: statusQuery.data?.limits.DERIVATIVE_CACHE_TTL_DAYS ?? 0, - }, - }); - - const cacheFitForm = useForm({ - defaultValues: { - max_gb: statusQuery.data?.limits.DERIVATIVE_CACHE_MAX_GB ?? 50, - }, - }); - - const syncLimitsForm = useForm({ - values: { - max_concurrent_pins: statusQuery.data?.limits.SYNC_MAX_CONCURRENT_PINS ?? 4, - disk_low_watermark_pct: statusQuery.data?.limits.SYNC_DISK_LOW_WATERMARK_PCT ?? 90, - }, - }); - - useEffect(() => { - cacheLimitsForm.reset({ - max_gb: statusQuery.data?.limits.DERIVATIVE_CACHE_MAX_GB ?? 50, - ttl_days: statusQuery.data?.limits.DERIVATIVE_CACHE_TTL_DAYS ?? 0, - }); - cacheFitForm.reset({ - max_gb: statusQuery.data?.limits.DERIVATIVE_CACHE_MAX_GB ?? 50, - }); - syncLimitsForm.reset({ - max_concurrent_pins: statusQuery.data?.limits.SYNC_MAX_CONCURRENT_PINS ?? 4, - disk_low_watermark_pct: statusQuery.data?.limits.SYNC_DISK_LOW_WATERMARK_PCT ?? 90, - }); - }, [statusQuery.data, cacheLimitsForm, cacheFitForm, syncLimitsForm]); - - const overviewCards = useMemo(() => { - const data = overviewQuery.data; - if (!data) { - return []; - } - return [ - { - label: "Хост", - value: data.project.host || "локальный" , - helper: data.project.name, - }, - { - label: "TON Master", - value: data.node.ton_master, - helper: "Платформа", - }, - { - label: "Service Wallet", - value: data.node.service_wallet, - helper: data.node.id, - }, - { - label: "Контент", - value: `${numberFormatter.format(data.content.encrypted_total)} зашифр.`, - helper: `${numberFormatter.format(data.content.derivatives_ready)} деривативов`, - }, - { - label: "IPFS Repo", - value: formatBytes( - Number((data.ipfs.repo as Record)?.RepoSize ?? 0), - ), - helper: "Размер репозитория", - }, - { - label: "Bitswap", - value: numberFormatter.format( - Number((data.ipfs.bitswap as Record)?.Peers ?? 0), - ), - helper: "Пиры", - }, - { - label: "Билд", - value: data.codebase.commit ?? "n/a", - helper: data.codebase.branch ?? "", - }, - { - label: "Python", - value: data.runtime.python, - helper: data.runtime.implementation, - }, - ]; - }, [overviewQuery.data]); - - const handleRefreshAll = async () => { - await queryClient.invalidateQueries({ - predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "admin", - }); - }; - - const renderFlash = () => { - if (!flash) { - return null; - } - const tone = - flash.type === "success" - ? "border-emerald-500/60 bg-emerald-500/10 text-emerald-100" - : flash.type === "error" - ? "border-rose-500/60 bg-rose-500/10 text-rose-100" - : "border-sky-500/60 bg-sky-500/10 text-sky-100"; - return ( -
- {flash.message} -
- ); - }; - - const renderLoginPanel = () => { - return ( -
-

Админ-панель узла

-

- Введите секретный токен ADMIN_API_TOKEN, чтобы получить доступ к мониторингу и управлению. -

-
{ - event.preventDefault(); - if (!token) { - setFlash({ type: "info", message: "Введите токен" }); - return; - } - loginMutation.mutate({ secret: token }); - }} - > -