1086 lines
46 KiB
TypeScript
1086 lines
46 KiB
TypeScript
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>
|
||
);
|
||
};
|