web2-client/src/pages/admin/index.tsx

1086 lines
46 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 (
<section id={id} className="rounded-2xl bg-slate-900/60 p-6 shadow-inner shadow-black/40 ring-1 ring-slate-800">
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h2 className="text-2xl font-semibold text-slate-100">{title}</h2>
{description ? (
<p className="mt-1 max-w-3xl text-sm text-slate-400">{description}</p>
) : null}
</div>
{actions ? <div className="flex h-full items-center gap-3">{actions}</div> : null}
</div>
<div className="space-y-6 text-sm text-slate-200">{children}</div>
</section>
);
};
type BadgeTone = "neutral" | "success" | "danger" | "warn";
const Badge = ({ children, tone = "neutral" }: { children: ReactNode; tone?: BadgeTone }) => {
const toneMap: Record<BadgeTone, string> = {
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 (
<span className={clsx("inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wide", toneMap[tone])}>
{children}
</span>
);
};
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<AuthState>(initialAuthState);
const [activeSection, setActiveSection] = useState<(typeof ADMIN_SECTIONS)[number]["id"]>("overview");
const [token, setToken] = useState("");
const [flash, setFlash] = useState<FlashMessage | null>(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<CacheLimitsFormValues>({
values: {
max_gb: statusQuery.data?.limits.DERIVATIVE_CACHE_MAX_GB ?? 50,
ttl_days: statusQuery.data?.limits.DERIVATIVE_CACHE_TTL_DAYS ?? 0,
},
});
const cacheFitForm = useForm<CacheFitFormValues>({
defaultValues: {
max_gb: statusQuery.data?.limits.DERIVATIVE_CACHE_MAX_GB ?? 50,
},
});
const syncLimitsForm = useForm<SyncLimitsFormValues>({
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<string, unknown>)?.RepoSize ?? 0),
),
helper: "Размер репозитория",
},
{
label: "Bitswap",
value: numberFormatter.format(
Number((data.ipfs.bitswap as Record<string, unknown>)?.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 (
<div className={clsx("mb-6 rounded-lg border px-4 py-3 text-sm", tone)}>
{flash.message}
</div>
);
};
const renderLoginPanel = () => {
return (
<div className="mx-auto max-w-xl rounded-2xl border border-slate-800 bg-slate-900/60 p-8 shadow-xl shadow-black/50">
<h1 className="text-3xl font-semibold text-slate-100">Админ-панель узла</h1>
<p className="mt-2 text-sm text-slate-400">
Введите секретный токен ADMIN_API_TOKEN, чтобы получить доступ к мониторингу и управлению.
</p>
<form
className="mt-6 space-y-4"
onSubmit={(event) => {
event.preventDefault();
if (!token) {
setFlash({ type: "info", message: "Введите токен" });
return;
}
loginMutation.mutate({ secret: token });
}}
>
<label className="block text-sm font-medium text-slate-200">
Админ-токен
<input
type="password"
className="mt-1 w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-base text-slate-100 shadow-inner focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/40"
value={token}
onChange={(event) => setToken(event.target.value)}
placeholder="••••••"
autoComplete="off"
/>
</label>
<div className="flex items-center justify-between">
<button
type="submit"
className="inline-flex items-center justify-center rounded-lg bg-sky-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/60 disabled:cursor-not-allowed disabled:bg-slate-700"
disabled={loginMutation.isLoading}
>
{loginMutation.isLoading ? "Проверяем…" : "Войти"}
</button>
<span className="text-xs text-slate-500">
Cookie max-age {Math.round(172800 / 3600)} ч
</span>
</div>
</form>
</div>
);
};
const renderOverview = () => {
if (!overviewQuery.data) {
return overviewQuery.isLoading ? <p>Загрузка обзора</p> : <p>Нет данных.</p>;
}
const { project, services, ton, runtime, ipfs } = overviewQuery.data;
return (
<Section
id="overview"
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={() => overviewQuery.refetch()}
>
Обновить
</button>
}
>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{overviewCards.map((card) => (
<div key={card.label} className="rounded-xl bg-slate-950/60 p-4 shadow-inner shadow-black/40 ring-1 ring-slate-800">
<p className="text-xs uppercase tracking-wide text-slate-500">{card.label}</p>
<p className="mt-2 break-all text-lg font-semibold text-slate-100">{card.value}</p>
{card.helper ? <p className="mt-1 text-xs text-slate-500">{card.helper}</p> : null}
</div>
))}
</div>
<div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-3">
<h3 className="text-lg font-semibold text-slate-100">Проект</h3>
<dl className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<InfoRow label="Хост">{project.host || "—"}</InfoRow>
<InfoRow label="Имя">{project.name}</InfoRow>
<InfoRow label="Приватность">{project.privacy}</InfoRow>
<InfoRow label="TON Testnet">{ton.testnet ? "Да" : "Нет"}</InfoRow>
<InfoRow label="TON API Key">{ton.api_key_configured ? "Настроен" : "Нет"}</InfoRow>
<InfoRow label="TON Host">{ton.host || "—"}</InfoRow>
</dl>
</div>
<div className="space-y-3">
<h3 className="text-lg font-semibold text-slate-100">Среда выполнения</h3>
<dl className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<InfoRow label="Python">{runtime.python}</InfoRow>
<InfoRow label="Имплементация">{runtime.implementation}</InfoRow>
<InfoRow label="Платформа">{runtime.platform}</InfoRow>
<InfoRow label="UTC сейчас">{formatDate(runtime.utc_now)}</InfoRow>
</dl>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-3">
<h3 className="text-lg font-semibold text-slate-100">IPFS</h3>
<div className="grid gap-3 rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
<InfoRow label="ID">{(ipfs.identity as Record<string, unknown>)?.ID ?? "—"}</InfoRow>
<InfoRow label="Agent">{(ipfs.identity as Record<string, unknown>)?.AgentVersion ?? "—"}</InfoRow>
<InfoRow label="Peers">{numberFormatter.format(Number((ipfs.bitswap as Record<string, unknown>)?.Peers ?? 0))}</InfoRow>
<InfoRow label="Repo size">{formatBytes(Number((ipfs.repo as Record<string, unknown>)?.RepoSize ?? 0))}</InfoRow>
<InfoRow label="Storage max">{formatBytes(Number((ipfs.repo as Record<string, unknown>)?.StorageMax ?? 0))}</InfoRow>
</div>
</div>
<div className="space-y-3">
<h3 className="text-lg font-semibold text-slate-100">Сервисы</h3>
<ul className="space-y-2">
{services.length === 0 ? (
<li className="text-sm text-slate-400">Нет зарегистрированных сервисов</li>
) : (
services.map((service) => {
const status = service.status ?? "—";
const tone: "success" | "warn" | "danger" | "neutral" = status.includes("working")
? "success"
: status.includes("timeout")
? "danger"
: "neutral";
return (
<li
key={service.name}
className="flex items-center justify-between rounded-lg bg-slate-950/40 px-3 py-2 ring-1 ring-slate-800"
>
<span className="text-sm font-medium text-slate-100">{service.name}</span>
<div className="flex items-center gap-3 text-xs text-slate-400">
<span>
{service.last_reported_seconds != null
? `Обновлён ${Math.round(service.last_reported_seconds)} сек назад`
: "Нет данных"}
</span>
<Badge tone={tone}>{status}</Badge>
</div>
</li>
);
})
)}
</ul>
</div>
</div>
</Section>
);
};
const renderStorage = () => {
if (storageQuery.isLoading) {
return <Section id="storage" title="Хранилище" description="Пути, размеры и деривативы">Загрузка</Section>;
}
if (!storageQuery.data) {
return null;
}
const { directories, disk, derivatives } = storageQuery.data;
return (
<Section id="storage" title="Хранилище" description="Пути на диске, использование и кэш деривативов">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-slate-800 text-left text-sm">
<thead className="bg-slate-950/70 text-xs uppercase tracking-wide text-slate-400">
<tr>
<th className="px-3 py-3">Путь</th>
<th className="px-3 py-3">Файлов</th>
<th className="px-3 py-3">Размер</th>
<th className="px-3 py-3">Состояние</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-900/60">
{directories.map((dir) => (
<tr key={dir.path} className="hover:bg-slate-900/50">
<td className="px-3 py-2 font-mono text-xs text-slate-300">{dir.path}</td>
<td className="px-3 py-2">{numberFormatter.format(dir.file_count)}</td>
<td className="px-3 py-2">{formatBytes(dir.size_bytes)}</td>
<td className="px-3 py-2">
<Badge tone={dir.exists ? "success" : "danger"}>{dir.exists ? "OK" : "Нет"}</Badge>
</td>
</tr>
))}
</tbody>
</table>
</div>
{disk ? (
<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>
<div className="mt-2 grid grid-cols-2 gap-3 text-sm">
<InfoRow label="Путь">{disk.path}</InfoRow>
<InfoRow label="Свободно">{formatBytes(disk.free_bytes)}</InfoRow>
<InfoRow label="Занято">{formatBytes(disk.used_bytes)}</InfoRow>
<InfoRow label="Всего">{formatBytes(disk.total_bytes)}</InfoRow>
<InfoRow label="Загруженность">{disk.percent_used != null ? `${disk.percent_used}%` : "—"}</InfoRow>
</div>
</div>
) : null}
<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>
<div className="mt-2 grid grid-cols-2 gap-3 text-sm sm:grid-cols-3">
<InfoRow label="Готово">{numberFormatter.format(derivatives.ready)}</InfoRow>
<InfoRow label="В обработке">{numberFormatter.format(derivatives.processing)}</InfoRow>
<InfoRow label="Ожидает">{numberFormatter.format(derivatives.pending)}</InfoRow>
<InfoRow label="С ошибкой">{numberFormatter.format(derivatives.failed)}</InfoRow>
<InfoRow label="Суммарно">{formatBytes(derivatives.total_bytes)}</InfoRow>
</div>
</div>
</Section>
);
};
const renderUploads = () => {
if (!uploadsQuery.data) {
return uploadsQuery.isLoading ? (
<Section id="uploads" title="Загрузки" description="Статистика сессий">Загрузка</Section>
) : null;
}
const { states, total, recent } = uploadsQuery.data;
return (
<Section
id="uploads"
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={() => uploadsQuery.refetch()}
>
Обновить
</button>
}
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<MetricCard label="Всего сессий" value={numberFormatter.format(total)} />
{Object.entries(states).map(([state, count]) => (
<MetricCard key={state} label={state} value={numberFormatter.format(count)} />
))}
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-slate-800 text-left text-sm">
<thead className="bg-slate-950/70 text-xs uppercase tracking-wide text-slate-400">
<tr>
<th className="px-3 py-3">ID</th>
<th className="px-3 py-3">Файл</th>
<th className="px-3 py-3">Размер</th>
<th className="px-3 py-3">Статус</th>
<th className="px-3 py-3">Обновлено</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-900/60">
{recent.map((item) => (
<tr key={item.id} className="hover:bg-slate-900/50">
<td className="px-3 py-2 font-mono text-xs text-slate-300">{item.id}</td>
<td className="px-3 py-2">{item.filename || "—"}</td>
<td className="px-3 py-2">{item.size_bytes ? formatBytes(item.size_bytes) : "—"}</td>
<td className="px-3 py-2"><Badge tone={item.state === "completed" ? "success" : item.state === "failed" ? "danger" : "neutral"}>{item.state}</Badge></td>
<td className="px-3 py-2">{formatDate(item.updated_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
</Section>
);
};
const renderSystem = () => {
if (!systemQuery.data) {
return systemQuery.isLoading ? (
<Section id="system" title="Система" description="Переменные окружения и конфиг">Загрузка</Section>
) : null;
}
const { env, service_config, services, blockchain_tasks, latest_index_items } = systemQuery.data;
return (
<Section id="system" title="Система" description="Ключевые переменные и внутренние конфиги">
<div className="grid gap-6 lg:grid-cols-2">
<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 space-y-2">
{Object.entries(env).map(([key, value]) => (
<InfoRow key={key} label={key}>{value ?? "—"}</InfoRow>
))}
</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">Задачи блокчейна</h3>
<dl className="mt-3 grid grid-cols-2 gap-3">
{Object.entries(blockchain_tasks).map(([key, value]) => (
<InfoRow key={key} label={key}>{numberFormatter.format(value)}</InfoRow>
))}
</dl>
<h4 className="mt-4 text-xs uppercase tracking-wide text-slate-500">Последние индексы</h4>
<ul className="mt-2 space-y-2">
{latest_index_items.length === 0 ? (
<li className="text-xs text-slate-500">Список пуст</li>
) : (
latest_index_items.map((item) => (
<li key={item.encrypted_cid || item.updated_at} className="rounded-lg bg-slate-900/60 px-3 py-2 text-xs">
<div className="font-mono text-slate-200">{item.encrypted_cid ?? "—"}</div>
<div className="text-slate-500">{formatDate(item.updated_at)}</div>
</li>
))
)}
</ul>
</div>
</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">ServiceConfig</h3>
<div className="mt-3 overflow-x-auto">
<table className="min-w-full divide-y divide-slate-800 text-left text-sm">
<thead className="bg-slate-950/70 text-xs uppercase tracking-wide text-slate-400">
<tr>
<th className="px-3 py-3">Ключ</th>
<th className="px-3 py-3">Значение</th>
<th className="px-3 py-3">RAW</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-900/60">
{service_config.map((item) => (
<tr key={item.key}>
<td className="px-3 py-2 font-mono text-xs text-slate-300">{item.key}</td>
<td className="px-3 py-2">{item.value ?? "—"}</td>
<td className="px-3 py-2 font-mono text-xs text-slate-500">{item.raw ?? "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
</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>
<ul className="mt-2 space-y-2">
{services.map((service) => (
<li key={service.name} className="flex items-center justify-between rounded-lg bg-slate-900/40 px-3 py-2">
<span className="text-sm text-slate-200">{service.name}</span>
<Badge tone={service.status?.includes("working") ? "success" : "neutral"}>{service.status ?? "—"}</Badge>
</li>
))}
</ul>
</div>
</Section>
);
};
const renderBlockchain = () => {
if (!blockchainQuery.data) {
return blockchainQuery.isLoading ? (
<Section id="blockchain" title="Блокчейн" description="Очередь задач">Загрузка</Section>
) : null;
}
const { counts, recent } = blockchainQuery.data;
return (
<Section
id="blockchain"
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={() => blockchainQuery.refetch()}
>
Обновить
</button>
}
>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Object.entries(counts).map(([key, value]) => (
<MetricCard key={key} label={key} value={numberFormatter.format(value)} />
))}
</div>
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-slate-800 text-left text-sm">
<thead className="bg-slate-950/70 text-xs uppercase tracking-wide text-slate-400">
<tr>
<th className="px-3 py-3">ID</th>
<th className="px-3 py-3">Назначение</th>
<th className="px-3 py-3">Сумма</th>
<th className="px-3 py-3">Статус</th>
<th className="px-3 py-3">Epoch · Seqno</th>
<th className="px-3 py-3">Hash</th>
<th className="px-3 py-3">Обновлено</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-900/60">
{recent.map((task) => (
<tr key={task.id} className="hover:bg-slate-900/50">
<td className="px-3 py-2 font-mono text-xs text-slate-300">{task.id}</td>
<td className="px-3 py-2">{task.destination || "—"}</td>
<td className="px-3 py-2">{task.amount || "—"}</td>
<td className="px-3 py-2"><Badge tone={task.status === "done" ? "success" : task.status === "failed" ? "danger" : "neutral"}>{task.status}</Badge></td>
<td className="px-3 py-2">{task.epoch ?? "—"} · {task.seqno ?? "—"}</td>
<td className="px-3 py-2 font-mono text-xs text-slate-500">{task.transaction_hash || "—"}</td>
<td className="px-3 py-2">{formatDate(task.updated)}</td>
</tr>
))}
</tbody>
</table>
</div>
</Section>
);
};
const renderNodes = () => {
if (!nodesQuery.data) {
return nodesQuery.isLoading ? (
<Section id="nodes" title="Ноды" description="Доверенные и внешние узлы">Загрузка</Section>
) : null;
}
const { items } = nodesQuery.data;
const handleRoleChange = (nodePublicKey: string | null, nodeIp: string | null) => {
return (event: ChangeEvent<HTMLSelectElement>) => {
const role = event.target.value as "trusted" | "read-only" | "deny";
nodeRoleMutation.mutate({
role,
public_key: nodePublicKey ?? undefined,
host: nodeIp ?? undefined,
});
};
};
return (
<Section id="nodes" title="Ноды" description="Роли, версии и последнее появление">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-slate-800 text-left text-sm">
<thead className="bg-slate-950/70 text-xs uppercase tracking-wide text-slate-400">
<tr>
<th className="px-3 py-3">IP</th>
<th className="px-3 py-3">Порт</th>
<th className="px-3 py-3">Публичный ключ</th>
<th className="px-3 py-3">Роль</th>
<th className="px-3 py-3">Версия</th>
<th className="px-3 py-3">Последний онлайн</th>
<th className="px-3 py-3">Заметки</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-900/60">
{items.map((node, index) => (
<tr key={node.public_key ?? node.ip ?? `node-${index}`} className="hover:bg-slate-900/50">
<td className="px-3 py-2">{node.ip || "—"}</td>
<td className="px-3 py-2">{node.port ?? "—"}</td>
<td className="px-3 py-2 font-mono text-xs text-slate-300">{node.public_key || "—"}</td>
<td className="px-3 py-2">
<select
className="rounded-lg border border-slate-700 bg-slate-950 px-2 py-1 text-xs text-slate-100 focus:border-sky-500 focus:outline-none"
defaultValue={node.role}
onChange={handleRoleChange(node.public_key, node.ip)}
disabled={nodeRoleMutation.isLoading}
>
<option value="trusted">trusted</option>
<option value="read-only">read-only</option>
<option value="deny">deny</option>
</select>
</td>
<td className="px-3 py-2">{node.version || "—"}</td>
<td className="px-3 py-2">{formatDate(node.last_seen)}</td>
<td className="px-3 py-2">{node.notes || "—"}</td>
</tr>
))}
</tbody>
</table>
</div>
</Section>
);
};
const renderStatus = () => {
if (!statusQuery.data) {
return statusQuery.isLoading ? (
<Section id="status" title="Статус & лимиты" description="IPFS, пины и лимиты">Загрузка</Section>
) : null;
}
const { ipfs, pin_counts, derivatives, convert_backlog, limits } = statusQuery.data;
return (
<Section id="status" title="Статус & лимиты" description="Информация о синхронизации, кэше и лимитах">
<div className="grid gap-6 xl:grid-cols-[1.8fr_1fr]">
<div className="space-y-6">
<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">IPFS</h3>
<dl className="mt-3 grid grid-cols-2 gap-3 text-sm">
<InfoRow label="Repo Size">{formatBytes(Number((ipfs.repo as Record<string, unknown>)?.RepoSize ?? 0))}</InfoRow>
<InfoRow label="Storage Max">{formatBytes(Number((ipfs.repo as Record<string, unknown>)?.StorageMax ?? 0))}</InfoRow>
<InfoRow label="Bitswap Peers">{numberFormatter.format(Number((ipfs.bitswap as Record<string, unknown>)?.Peers ?? 0))}</InfoRow>
<InfoRow label="Blocks Received">{numberFormatter.format(Number((ipfs.bitswap as Record<string, unknown>)?.BlocksReceived ?? 0))}</InfoRow>
</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">Pin-статистика</h3>
<dl className="mt-3 grid grid-cols-2 gap-3 text-sm">
{Object.entries(pin_counts).map(([key, value]) => (
<InfoRow key={key} label={key}>{numberFormatter.format(value)}</InfoRow>
))}
<InfoRow label="Backlog деривативов">{numberFormatter.format(convert_backlog)}</InfoRow>
<InfoRow label="Деривативы">{formatBytes(derivatives.total_bytes)}</InfoRow>
</dl>
</div>
</div>
<div className="space-y-6">
<form
className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800"
onSubmit={cacheLimitsForm.handleSubmit((values) => {
cacheLimitsMutation.mutate(values);
})}
>
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-400">Лимиты кэша</h3>
<div className="mt-3 space-y-3 text-sm">
<label className="block">
<span className="text-xs uppercase text-slate-400">Максимум, GB</span>
<input
type="number"
step="0.1"
className="mt-1 w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-slate-100 focus:border-sky-500 focus:outline-none"
{...cacheLimitsForm.register("max_gb", { valueAsNumber: true })}
/>
</label>
<label className="block">
<span className="text-xs uppercase text-slate-400">TTL, дней</span>
<input
type="number"
className="mt-1 w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-slate-100 focus:border-sky-500 focus:outline-none"
{...cacheLimitsForm.register("ttl_days", { valueAsNumber: true })}
/>
</label>
</div>
<button
type="submit"
className="mt-4 w-full rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:bg-slate-700"
disabled={cacheLimitsMutation.isLoading}
>
{cacheLimitsMutation.isLoading ? "Сохраняем…" : "Сохранить"}
</button>
</form>
<form
className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800"
onSubmit={cacheFitForm.handleSubmit((values) => {
cacheCleanupMutation.mutate({ mode: "fit", max_gb: values.max_gb });
})}
>
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-400">Очистка по размеру</h3>
<label className="mt-3 block text-sm">
<span className="text-xs uppercase text-slate-400">Целевой размер, GB</span>
<input
type="number"
step="0.1"
className="mt-1 w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-slate-100 focus:border-sky-500 focus:outline-none"
{...cacheFitForm.register("max_gb", { valueAsNumber: true })}
/>
</label>
<div className="mt-4 flex flex-col gap-2">
<button
type="submit"
className="w-full rounded-lg bg-sky-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-sky-500 disabled:cursor-not-allowed disabled:bg-slate-700"
disabled={cacheCleanupMutation.isLoading}
>
{cacheCleanupMutation.isLoading ? "Очищаем…" : "Очистить до размера"}
</button>
<button
type="button"
onClick={() => cacheCleanupMutation.mutate({ mode: "ttl" })}
className="w-full rounded-lg bg-amber-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-amber-500 disabled:cursor-not-allowed disabled:bg-slate-700"
disabled={cacheCleanupMutation.isLoading}
>
Очистить по TTL
</button>
</div>
</form>
<form
className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800"
onSubmit={syncLimitsForm.handleSubmit((values) => {
syncLimitsMutation.mutate(values);
})}
>
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-400">Sync лимиты</h3>
<div className="mt-3 space-y-3 text-sm">
<label className="block">
<span className="text-xs uppercase text-slate-400">Одновременные пины</span>
<input
type="number"
className="mt-1 w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-slate-100 focus:border-sky-500 focus:outline-none"
{...syncLimitsForm.register("max_concurrent_pins", { valueAsNumber: true })}
/>
</label>
<label className="block">
<span className="text-xs uppercase text-slate-400">Нижний предел диска, %</span>
<input
type="number"
className="mt-1 w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-slate-100 focus:border-sky-500 focus:outline-none"
{...syncLimitsForm.register("disk_low_watermark_pct", { valueAsNumber: true })}
/>
</label>
</div>
<button
type="submit"
className="mt-4 w-full rounded-lg bg-emerald-600 px-4 py-2 text-sm font-semibold text-white transition hover:bg-emerald-500 disabled:cursor-not-allowed disabled:bg-slate-700"
disabled={syncLimitsMutation.isLoading}
>
{syncLimitsMutation.isLoading ? "Сохраняем…" : "Сохранить"}
</button>
</form>
</div>
</div>
</Section>
);
};
const renderDashboard = () => {
if (authState === "checking" && overviewQuery.isLoading) {
return (
<div className="flex h-[60vh] items-center justify-center text-slate-400">Проверяем сессию</div>
);
}
return (
<div className="space-y-10">
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<div>
<h1 className="text-3xl font-semibold text-slate-50">Админ-панель</h1>
<p className="mt-1 text-sm text-slate-400">
Управление узлом, мониторинг IPFS/TON и контроль кэша
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
onClick={handleRefreshAll}
className="rounded-lg bg-slate-800 px-4 py-2 text-sm font-semibold text-slate-200 ring-1 ring-slate-700 transition hover:bg-slate-700"
>
Обновить всё
</button>
<button
type="button"
onClick={() => logoutMutation.mutate()}
className="rounded-lg bg-rose-700 px-4 py-2 text-sm font-semibold text-white transition hover:bg-rose-600"
disabled={logoutMutation.isLoading}
>
Выйти
</button>
</div>
</div>
<nav className="sticky top-6 z-10 rounded-2xl border border-slate-800 bg-slate-950/80 backdrop-blur">
<ul className="flex flex-wrap">
{ADMIN_SECTIONS.map((section) => (
<li key={section.id} className="flex-1">
<button
type="button"
className={clsx(
"w-full px-4 py-3 text-xs font-semibold uppercase tracking-wide transition",
activeSection === section.id
? "bg-slate-900 text-slate-100"
: "text-slate-400 hover:bg-slate-900/60 hover:text-slate-100",
)}
onClick={() => {
setActiveSection(section.id);
document.getElementById(section.id)?.scrollIntoView({ behavior: "smooth", block: "start" });
}}
>
{section.label}
</button>
</li>
))}
</ul>
</nav>
{renderOverview()}
{renderStorage()}
{renderUploads()}
{renderSystem()}
{renderBlockchain()}
{renderNodes()}
{renderStatus()}
</div>
);
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 pb-20 text-slate-100">
<div className="mx-auto max-w-7xl px-6 py-10">
{renderFlash()}
{authState === "authorized" || authState === "checking" ? renderDashboard() : renderLoginPanel()}
</div>
</div>
);
};
const InfoRow = ({ label, children }: { label: string; children: ReactNode }) => {
return (
<div>
<dt className="text-xs uppercase tracking-wide text-slate-500">{label}</dt>
<dd className="mt-1 text-sm text-slate-100">{children}</dd>
</div>
);
};
const MetricCard = ({ label, value }: { label: string; value: string }) => {
return (
<div className="rounded-xl bg-slate-950/60 p-4 shadow-inner shadow-black/40 ring-1 ring-slate-800">
<p className="text-xs uppercase tracking-wide text-slate-500">{label}</p>
<p className="mt-2 text-2xl font-semibold text-slate-100">{value}</p>
</div>
);
};