import { ChangeEvent, ReactNode, useEffect, useMemo, useState } from "react"; import { useForm } from "react-hook-form"; import { useQueryClient } from "react-query"; import clsx from "clsx"; import { isUnauthorizedError, useAdminBlockchain, useAdminCacheCleanup, useAdminCacheSetLimits, useAdminLogin, useAdminLogout, useAdminNodeSetRole, useAdminNodes, useAdminOverview, useAdminStatus, useAdminStorage, useAdminSyncSetLimits, useAdminSystem, useAdminUploads, } from "~/shared/services/admin"; import { clearAdminAuth, getAdminAuthSnapshot } from "~/shared/libs/admin-auth"; const numberFormatter = new Intl.NumberFormat("ru-RU"); const dateTimeFormatter = new Intl.DateTimeFormat("ru-RU", { dateStyle: "short", timeStyle: "medium", }); const formatBytes = (input?: number | null) => { if (!input) { return "0 B"; } 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]}`; }; const formatDate = (iso?: string | null) => { if (!iso) { return "—"; } const date = new Date(iso); if (Number.isNaN(date.getTime())) { return iso; } return dateTimeFormatter.format(date); }; const Section = ({ id, title, description, children, actions, }: { id: string; title: string; description?: string; children: ReactNode; actions?: ReactNode; }) => { 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} ); }; 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: "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; }; const initialAuthState = getAdminAuthSnapshot().token ? "checking" : "unauthorized"; export const AdminPage = () => { 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); useEffect(() => { if (!flash) { return; } const timeout = setTimeout(() => setFlash(null), 6000); return () => clearTimeout(timeout); }, [flash]); useEffect(() => { if (authState === "unauthorized") { queryClient.removeQueries({ predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "admin", }); } }, [authState, queryClient]); const overviewQuery = useAdminOverview({ enabled: authState !== "unauthorized", retry: false, onSuccess: () => { setAuthState((prev) => (prev === "authorized" ? prev : "authorized")); }, onError: (error) => { if (isUnauthorizedError(error)) { clearAdminAuth(); setAuthState("unauthorized"); setFlash({ type: "info", message: "Введите админ-токен, чтобы продолжить" }); return; } setFlash({ type: "error", message: `Не удалось загрузить обзор: ${error.message}` }); }, }); const isDataEnabled = authState === "authorized"; const storageQuery = useAdminStorage({ enabled: isDataEnabled, refetchInterval: 60_000, }); const uploadsQuery = useAdminUploads({ enabled: isDataEnabled, refetchInterval: 30_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", }); }, onError: (error) => { if (isUnauthorizedError(error)) { clearAdminAuth(); setFlash({ type: "error", message: "Неверный токен" }); return; } setFlash({ type: "error", message: `Ошибка входа: ${error.message}` }); }, }); const logoutMutation = useAdminLogout({ onSuccess: async () => { setAuthState("unauthorized"); setFlash({ type: "info", message: "Сессия завершена" }); await queryClient.invalidateQueries({ predicate: (query) => Array.isArray(query.queryKey) && query.queryKey[0] === "admin", }); }, onError: (error) => { setFlash({ type: "error", message: `Ошибка выхода: ${error.message}` }); }, }); const cacheLimitsMutation = useAdminCacheSetLimits({ onSuccess: async () => { setFlash({ type: "success", message: "Лимиты кэша обновлены" }); await statusQuery.refetch(); }, onError: (error) => { setFlash({ type: "error", message: `Не удалось сохранить лимиты кэша: ${error.message}` }); }, }); 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 }); }} >
Cookie max-age {Math.round(172800 / 3600)} ч
); }; const renderOverview = () => { if (!overviewQuery.data) { return overviewQuery.isLoading ?

Загрузка обзора…

:

Нет данных.

; } const { project, services, ton, runtime, ipfs } = overviewQuery.data; return (
overviewQuery.refetch()} > Обновить } >
{overviewCards.map((card) => (

{card.label}

{card.value}

{card.helper ?

{card.helper}

: null}
))}

Проект

{project.host || "—"} {project.name} {project.privacy} {ton.testnet ? "Да" : "Нет"} {ton.api_key_configured ? "Настроен" : "Нет"} {ton.host || "—"}

Среда выполнения

{runtime.python} {runtime.implementation} {runtime.platform} {formatDate(runtime.utc_now)}

IPFS

{(ipfs.identity as Record)?.ID ?? "—"} {(ipfs.identity as Record)?.AgentVersion ?? "—"} {numberFormatter.format(Number((ipfs.bitswap as Record)?.Peers ?? 0))} {formatBytes(Number((ipfs.repo as Record)?.RepoSize ?? 0))} {formatBytes(Number((ipfs.repo as Record)?.StorageMax ?? 0))}

Сервисы

    {services.length === 0 ? (
  • Нет зарегистрированных сервисов
  • ) : ( services.map((service) => { const status = service.status ?? "—"; const tone: "success" | "warn" | "danger" | "neutral" = status.includes("working") ? "success" : status.includes("timeout") ? "danger" : "neutral"; return (
  • {service.name}
    {service.last_reported_seconds != null ? `Обновлён ${Math.round(service.last_reported_seconds)} сек назад` : "Нет данных"} {status}
  • ); }) )}
); }; const renderStorage = () => { if (storageQuery.isLoading) { return
Загрузка…
; } if (!storageQuery.data) { return null; } const { directories, disk, derivatives } = storageQuery.data; return (
{directories.map((dir) => ( ))}
Путь Файлов Размер Состояние
{dir.path} {numberFormatter.format(dir.file_count)} {formatBytes(dir.size_bytes)} {dir.exists ? "OK" : "Нет"}
{disk ? (

Снимок диска

{disk.path} {formatBytes(disk.free_bytes)} {formatBytes(disk.used_bytes)} {formatBytes(disk.total_bytes)} {disk.percent_used != null ? `${disk.percent_used}%` : "—"}
) : null}

Деривативы

{numberFormatter.format(derivatives.ready)} {numberFormatter.format(derivatives.processing)} {numberFormatter.format(derivatives.pending)} {numberFormatter.format(derivatives.failed)} {formatBytes(derivatives.total_bytes)}
); }; const renderUploads = () => { if (!uploadsQuery.data) { return uploadsQuery.isLoading ? (
Загрузка…
) : null; } const { states, total, recent } = uploadsQuery.data; return (
uploadsQuery.refetch()} > Обновить } >
{Object.entries(states).map(([state, count]) => ( ))}
{recent.map((item) => ( ))}
ID Файл Размер Статус Обновлено
{item.id} {item.filename || "—"} {item.size_bytes ? formatBytes(item.size_bytes) : "—"} {item.state} {formatDate(item.updated_at)}
); }; const renderSystem = () => { if (!systemQuery.data) { return systemQuery.isLoading ? (
Загрузка…
) : null; } const { env, service_config, services, blockchain_tasks, latest_index_items } = systemQuery.data; return (

Окружение

{Object.entries(env).map(([key, value]) => ( {value ?? "—"} ))}

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

{Object.entries(blockchain_tasks).map(([key, value]) => ( {numberFormatter.format(value)} ))}

Последние индексы

    {latest_index_items.length === 0 ? (
  • Список пуст
  • ) : ( latest_index_items.map((item) => (
  • {item.encrypted_cid ?? "—"}
    {formatDate(item.updated_at)}
  • )) )}

ServiceConfig

{service_config.map((item) => ( ))}
Ключ Значение RAW
{item.key} {item.value ?? "—"} {item.raw ?? "—"}

Сервисы

    {services.map((service) => (
  • {service.name} {service.status ?? "—"}
  • ))}
); }; const renderBlockchain = () => { if (!blockchainQuery.data) { return blockchainQuery.isLoading ? (
Загрузка…
) : null; } const { counts, recent } = blockchainQuery.data; return (
blockchainQuery.refetch()} > Обновить } >
{Object.entries(counts).map(([key, value]) => ( ))}
{recent.map((task) => ( ))}
ID Назначение Сумма Статус Epoch · Seqno Hash Обновлено
{task.id} {task.destination || "—"} {task.amount || "—"} {task.status} {task.epoch ?? "—"} · {task.seqno ?? "—"} {task.transaction_hash || "—"} {formatDate(task.updated)}
); }; const renderNodes = () => { if (!nodesQuery.data) { return nodesQuery.isLoading ? (
Загрузка…
) : null; } const { items } = nodesQuery.data; const handleRoleChange = (nodePublicKey: string | null, nodeIp: string | null) => { return (event: ChangeEvent) => { const role = event.target.value as "trusted" | "read-only" | "deny"; nodeRoleMutation.mutate({ role, public_key: nodePublicKey ?? undefined, host: nodeIp ?? undefined, }); }; }; return (
{items.map((node, index) => ( ))}
IP Порт Публичный ключ Роль Версия Последний онлайн Заметки
{node.ip || "—"} {node.port ?? "—"} {node.public_key || "—"} {node.version || "—"} {formatDate(node.last_seen)} {node.notes || "—"}
); }; const renderStatus = () => { if (!statusQuery.data) { return statusQuery.isLoading ? (
Загрузка…
) : null; } const { ipfs, pin_counts, derivatives, convert_backlog, limits } = statusQuery.data; return (

IPFS

{formatBytes(Number((ipfs.repo as Record)?.RepoSize ?? 0))} {formatBytes(Number((ipfs.repo as Record)?.StorageMax ?? 0))} {numberFormatter.format(Number((ipfs.bitswap as Record)?.Peers ?? 0))} {numberFormatter.format(Number((ipfs.bitswap as Record)?.BlocksReceived ?? 0))}

Pin-статистика

{Object.entries(pin_counts).map(([key, value]) => ( {numberFormatter.format(value)} ))} {numberFormatter.format(convert_backlog)} {formatBytes(derivatives.total_bytes)}
{ cacheLimitsMutation.mutate(values); })} >

Лимиты кэша

{ cacheCleanupMutation.mutate({ mode: "fit", max_gb: values.max_gb }); })} >

Очистка по размеру

{ syncLimitsMutation.mutate(values); })} >

Sync лимиты

); }; const renderDashboard = () => { if (authState === "checking" && overviewQuery.isLoading) { return (
Проверяем сессию…
); } return (

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

Управление узлом, мониторинг IPFS/TON и контроль кэша

{renderOverview()} {renderStorage()} {renderUploads()} {renderSystem()} {renderBlockchain()} {renderNodes()} {renderStatus()}
); }; return (
{renderFlash()} {authState === "authorized" || authState === "checking" ? renderDashboard() : renderLoginPanel()}
); }; const InfoRow = ({ label, children }: { label: string; children: ReactNode }) => { return (
{label}
{children}
); }; const MetricCard = ({ label, value }: { label: string; value: string }) => { return (

{label}

{value}

); };