admin fix
This commit is contained in:
parent
26783c2d41
commit
82f205bf5c
|
|
@ -3,7 +3,19 @@ import { createBrowserRouter, RouterProvider } from "react-router-dom";
|
|||
import { Routes } from "~/app/router/constants";
|
||||
import { RootPage } from "~/pages/root";
|
||||
import { ViewContentPage } from "~/pages/view-content";
|
||||
import { AdminPage } from "~/pages/admin";
|
||||
import { AdminPage, AdminIndexRedirect } from "~/pages/admin";
|
||||
import {
|
||||
AdminOverviewPage,
|
||||
AdminStoragePage,
|
||||
AdminUploadsPage,
|
||||
AdminUsersPage,
|
||||
AdminLicensesPage,
|
||||
AdminStarsPage,
|
||||
AdminSystemPage,
|
||||
AdminBlockchainPage,
|
||||
AdminNodesPage,
|
||||
AdminStatusPage,
|
||||
} from "~/pages/admin/sections";
|
||||
import { ProtectedLayout } from "./protected-layout";
|
||||
|
||||
const router = createBrowserRouter([
|
||||
|
|
@ -30,7 +42,23 @@ const router = createBrowserRouter([
|
|||
},
|
||||
],
|
||||
},
|
||||
{ path: Routes.Admin, element: <AdminPage /> },
|
||||
{
|
||||
path: Routes.Admin,
|
||||
element: <AdminPage />,
|
||||
children: [
|
||||
{ index: true, element: <AdminIndexRedirect /> },
|
||||
{ path: "overview", element: <AdminOverviewPage /> },
|
||||
{ path: "storage", element: <AdminStoragePage /> },
|
||||
{ path: "uploads", element: <AdminUploadsPage /> },
|
||||
{ path: "users", element: <AdminUsersPage /> },
|
||||
{ path: "licenses", element: <AdminLicensesPage /> },
|
||||
{ path: "stars", element: <AdminStarsPage /> },
|
||||
{ path: "system", element: <AdminSystemPage /> },
|
||||
{ path: "blockchain", element: <AdminBlockchainPage /> },
|
||||
{ path: "nodes", element: <AdminNodesPage /> },
|
||||
{ path: "status", element: <AdminStatusPage /> },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
export const AppRouter = () => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,24 @@
|
|||
import type { ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
type BadgeTone = "neutral" | "success" | "danger" | "warn";
|
||||
|
||||
const toneClassMap: 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",
|
||||
};
|
||||
|
||||
export const Badge = ({ children, tone = "neutral" }: { children: ReactNode; tone?: BadgeTone }) => {
|
||||
return (
|
||||
<span
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-semibold uppercase tracking-wide",
|
||||
toneClassMap[tone],
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
export const InfoRow = ({ label, children }: { label: string; children: ReactNode }) => {
|
||||
return (
|
||||
<div className="flex flex-col gap-1 rounded-lg border border-slate-800 bg-slate-950/60 p-3">
|
||||
<span className="text-xs uppercase tracking-wide text-slate-500">{label}</span>
|
||||
<span className="text-sm text-slate-100">{children}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
type PaginationControlsProps = {
|
||||
total: number;
|
||||
limit: number;
|
||||
page: number;
|
||||
onPageChange: (next: number) => void;
|
||||
};
|
||||
|
||||
const numberFormatter = new Intl.NumberFormat("ru-RU");
|
||||
|
||||
export const PaginationControls = ({ total, limit, page, onPageChange }: PaginationControlsProps) => {
|
||||
const safeLimit = Math.max(limit, 1);
|
||||
const totalPages = Math.max(1, Math.ceil(Math.max(total, 0) / safeLimit));
|
||||
const clampedPage = Math.min(Math.max(page, 0), totalPages - 1);
|
||||
const startIndex = total === 0 ? 0 : clampedPage * safeLimit + 1;
|
||||
const endIndex = total === 0 ? 0 : Math.min(total, (clampedPage + 1) * safeLimit);
|
||||
const canGoPrev = clampedPage > 0;
|
||||
const canGoNext = clampedPage + 1 < totalPages;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 rounded-xl bg-slate-950/40 px-3 py-2 ring-1 ring-slate-800 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-xs text-slate-400">
|
||||
{total === 0
|
||||
? "Нет записей"
|
||||
: `Показаны ${numberFormatter.format(startIndex)}–${numberFormatter.format(endIndex)} из ${numberFormatter.format(total)}`}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-slate-900/60 px-3 py-1 text-xs font-semibold text-slate-300 ring-1 ring-slate-700 transition hover:bg-slate-800 disabled:bg-slate-900/30 disabled:text-slate-600 disabled:ring-slate-800"
|
||||
onClick={() => onPageChange(Math.max(clampedPage - 1, 0))}
|
||||
disabled={!canGoPrev}
|
||||
>
|
||||
Назад
|
||||
</button>
|
||||
<span className="text-xs text-slate-300">
|
||||
Стр. {numberFormatter.format(clampedPage + 1)} из {numberFormatter.format(totalPages)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-slate-900/60 px-3 py-1 text-xs font-semibold text-slate-300 ring-1 ring-slate-700 transition hover:bg-slate-800 disabled:bg-slate-900/30 disabled:text-slate-600 disabled:ring-slate-800"
|
||||
onClick={() => onPageChange(Math.min(clampedPage + 1, totalPages - 1))}
|
||||
disabled={!canGoNext}
|
||||
>
|
||||
Вперёд
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import type { ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
type SectionProps = {
|
||||
id?: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export const Section = ({ id, title, description, actions, children, className }: SectionProps) => {
|
||||
return (
|
||||
<section
|
||||
id={id}
|
||||
className={clsx(
|
||||
"rounded-2xl bg-slate-900/60 p-6 shadow-inner shadow-black/40 ring-1 ring-slate-800",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
export * from "./Section";
|
||||
export * from "./Badge";
|
||||
export * from "./PaginationControls";
|
||||
export * from "./InfoRow";
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import type { ReactNode } from "react";
|
||||
|
||||
export type AdminSectionId =
|
||||
| "overview"
|
||||
| "storage"
|
||||
| "uploads"
|
||||
| "users"
|
||||
| "licenses"
|
||||
| "stars"
|
||||
| "system"
|
||||
| "blockchain"
|
||||
| "nodes"
|
||||
| "status";
|
||||
|
||||
export type AdminSection = {
|
||||
id: AdminSectionId;
|
||||
label: string;
|
||||
description: string;
|
||||
path: string;
|
||||
icon?: ReactNode;
|
||||
};
|
||||
|
||||
export const ADMIN_SECTIONS: AdminSection[] = [
|
||||
{ id: "overview", label: "Обзор", description: "Краткий срез состояния узла, окружения и служб", path: "overview" },
|
||||
{ id: "storage", label: "Хранилище", description: "Загруженность диска и директории контента", path: "storage" },
|
||||
{ id: "uploads", label: "Загрузки", description: "Отслеживание статуса загрузок и деривативов", path: "uploads" },
|
||||
{ id: "users", label: "Пользователи", description: "Мониторинг пользователей, кошельков и активности", path: "users" },
|
||||
{ id: "licenses", label: "Лицензии", description: "Статус лицензий и фильтрация по типам", path: "licenses" },
|
||||
{ id: "stars", label: "Платежи Stars", description: "Управление счетами и анализ платежей", path: "stars" },
|
||||
{ id: "system", label: "Система", description: "Настройки окружения и состояние сервисов", path: "system" },
|
||||
{ id: "blockchain", label: "Блокчейн", description: "История задач и метрики блокчейн-интеграции", path: "blockchain" },
|
||||
{ id: "nodes", label: "Ноды", description: "Роли, версии и последнее появление узлов", path: "nodes" },
|
||||
{ id: "status", label: "Статус & лимиты", description: "IPFS, очереди и лимиты синхронизации", path: "status" },
|
||||
];
|
||||
|
||||
export const DEFAULT_ADMIN_SECTION = ADMIN_SECTIONS[0];
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { createContext, useContext } from "react";
|
||||
|
||||
import type { AdminContextValue } from "./types";
|
||||
|
||||
export const AdminContext = createContext<AdminContextValue | undefined>(undefined);
|
||||
|
||||
export const useAdminContext = () => {
|
||||
const ctx = useContext(AdminContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useAdminContext must be used within <AdminContext.Provider>");
|
||||
}
|
||||
return ctx;
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,91 @@
|
|||
import { useAdminBlockchain } from "~/shared/services/admin";
|
||||
import { Section, Badge } from "../components";
|
||||
import { useAdminContext } from "../context";
|
||||
import { formatDate, formatUnknown, numberFormatter } from "../utils/format";
|
||||
|
||||
const MetricCard = ({ label, value }: { label: string; value: string }) => {
|
||||
return (
|
||||
<div className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500">{label}</p>
|
||||
<p className="mt-2 text-lg font-semibold text-slate-100">{value}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AdminBlockchainPage = () => {
|
||||
const { isAuthorized, handleRequestError } = useAdminContext();
|
||||
|
||||
const blockchainQuery = useAdminBlockchain({
|
||||
enabled: isAuthorized,
|
||||
refetchInterval: 30_000,
|
||||
onError: (error) => handleRequestError(error, "Не удалось загрузить данные блокчейна"),
|
||||
});
|
||||
|
||||
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()}
|
||||
disabled={blockchainQuery.isFetching}
|
||||
>
|
||||
{blockchainQuery.isFetching ? "Обновляем…" : "Обновить"}
|
||||
</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">{formatUnknown(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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,232 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useAdminLicenses } from "~/shared/services/admin";
|
||||
import { Section, PaginationControls } from "../components";
|
||||
import { useAdminContext } from "../context";
|
||||
import { formatDate, numberFormatter } from "../utils/format";
|
||||
|
||||
export const AdminLicensesPage = () => {
|
||||
const { isAuthorized, handleRequestError } = useAdminContext();
|
||||
const [search, setSearch] = useState("");
|
||||
const [statusFilter, setStatusFilter] = useState("all");
|
||||
const [typeFilter, setTypeFilter] = useState("all");
|
||||
const [licenseTypeFilter, setLicenseTypeFilter] = useState("all");
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const limit = 50;
|
||||
const normalizedSearch = search.trim();
|
||||
|
||||
const licensesQuery = useAdminLicenses(
|
||||
{
|
||||
limit,
|
||||
offset: page * limit,
|
||||
search: normalizedSearch || undefined,
|
||||
status: statusFilter !== "all" ? statusFilter : undefined,
|
||||
type: typeFilter !== "all" ? typeFilter : undefined,
|
||||
license_type: licenseTypeFilter !== "all" ? licenseTypeFilter : undefined,
|
||||
},
|
||||
{
|
||||
enabled: isAuthorized,
|
||||
keepPreviousData: true,
|
||||
refetchInterval: 60_000,
|
||||
onError: (error) => handleRequestError(error, "Не удалось загрузить лицензии"),
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
}, [normalizedSearch, statusFilter, typeFilter, licenseTypeFilter]);
|
||||
|
||||
if (licensesQuery.isLoading && !licensesQuery.data) {
|
||||
return (
|
||||
<Section id="licenses" title="Лицензии" description="On-chain лицензии и связанные активы">
|
||||
Загрузка…
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!licensesQuery.data) {
|
||||
return (
|
||||
<Section id="licenses" title="Лицензии" description="On-chain лицензии и связанные активы">
|
||||
<p className="text-sm text-slate-400">Выберите вкладку «Лицензии», чтобы загрузить данные.</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
const { items, counts, total } = licensesQuery.data;
|
||||
const statusOptions = Object.keys(counts.status || {});
|
||||
const typeOptions = Object.keys(counts.type || {});
|
||||
const kindOptions = Object.keys(counts.license_type || {});
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="licenses"
|
||||
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={() => licensesQuery.refetch()}
|
||||
disabled={licensesQuery.isFetching}
|
||||
>
|
||||
{licensesQuery.isFetching ? "Обновляем…" : "Обновить"}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-end">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Поиск</label>
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="Адрес, владелец, hash"
|
||||
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-500 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 md:w-64"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Статус</label>
|
||||
<select
|
||||
value={statusFilter}
|
||||
onChange={(event) => setStatusFilter(event.target.value)}
|
||||
className="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
||||
>
|
||||
<option value="all">Все</option>
|
||||
{statusOptions.map((option) => (
|
||||
<option key={`license-status-${option}`} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Тип</label>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(event) => setTypeFilter(event.target.value)}
|
||||
className="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
||||
>
|
||||
<option value="all">Все</option>
|
||||
{typeOptions.map((option) => (
|
||||
<option key={`license-type-${option}`} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">license_type</label>
|
||||
<select
|
||||
value={licenseTypeFilter}
|
||||
onChange={(event) => setLicenseTypeFilter(event.target.value)}
|
||||
className="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
||||
>
|
||||
<option value="all">Все</option>
|
||||
{kindOptions.map((option) => (
|
||||
<option key={`license-kind-${option}`} value={option}>
|
||||
{option === "unknown" ? "Не указано" : option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-slate-900 px-3 py-2 text-xs font-semibold text-slate-300 ring-1 ring-slate-700 transition hover:bg-slate-800"
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
setStatusFilter("all");
|
||||
setTypeFilter("all");
|
||||
setLicenseTypeFilter("all");
|
||||
}}
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 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">On-chain</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.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-3 py-6 text-center text-sm text-slate-400">
|
||||
Ничего не найдено.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
items.map((license) => (
|
||||
<tr key={`license-${license.id}`} className="hover:bg-slate-900/40">
|
||||
<td className="px-3 py-3 align-top">
|
||||
<div className="font-semibold text-slate-100">#{numberFormatter.format(license.id)}</div>
|
||||
<div className="text-xs text-slate-400">{license.type ?? "—"} · {license.status ?? "—"}</div>
|
||||
<div className="text-[10px] uppercase tracking-wide text-slate-500">
|
||||
license_type: {license.license_type ?? "—"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top">
|
||||
{license.onchain_address ? (
|
||||
<a
|
||||
href={`https://tonviewer.com/${license.onchain_address}`}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="break-all text-xs text-sky-300 hover:text-sky-200"
|
||||
>
|
||||
{license.onchain_address}
|
||||
</a>
|
||||
) : (
|
||||
<div className="text-xs text-slate-500">—</div>
|
||||
)}
|
||||
<div className="text-[10px] uppercase tracking-wide text-slate-500">
|
||||
Владелец: {license.owner_address ?? "—"}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top">
|
||||
{license.user ? (
|
||||
<div className="text-xs text-slate-300">
|
||||
ID {numberFormatter.format(license.user.id)} · TG {numberFormatter.format(license.user.telegram_id)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-slate-500">—</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top">
|
||||
{license.content ? (
|
||||
<>
|
||||
<div className="text-xs text-slate-300">{license.content.title}</div>
|
||||
<div className="break-all text-[10px] uppercase tracking-wide text-slate-500">
|
||||
{license.content.hash}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-xs text-slate-500">—</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top text-xs text-slate-400">
|
||||
<div>Создано: {formatDate(license.created_at)}</div>
|
||||
<div>Обновлено: {formatDate(license.updated_at)}</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<PaginationControls total={total} limit={limit} page={page} onPageChange={setPage} />
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import { ChangeEvent, useMemo } from "react";
|
||||
|
||||
import { useAdminNodeSetRole, useAdminNodes } from "~/shared/services/admin";
|
||||
import { Section } from "../components";
|
||||
import { useAdminContext } from "../context";
|
||||
import { formatDate, formatUnknown } from "../utils/format";
|
||||
|
||||
export const AdminNodesPage = () => {
|
||||
const { isAuthorized, handleRequestError, pushFlash } = useAdminContext();
|
||||
|
||||
const nodesQuery = useAdminNodes({
|
||||
enabled: isAuthorized,
|
||||
refetchInterval: 60_000,
|
||||
onError: (error) => handleRequestError(error, "Не удалось загрузить список нод"),
|
||||
});
|
||||
|
||||
const nodeRoleMutation = useAdminNodeSetRole({
|
||||
onSuccess: async ({ node }) => {
|
||||
pushFlash({
|
||||
type: "success",
|
||||
message: `Роль узла ${node.public_key ?? node.ip ?? ""} обновлена до ${node.role}`,
|
||||
});
|
||||
await nodesQuery.refetch();
|
||||
},
|
||||
onError: (error) => handleRequestError(error, "Не удалось обновить роль узла"),
|
||||
});
|
||||
|
||||
const items = nodesQuery.data?.items ?? [];
|
||||
|
||||
const handleRoleChange = (publicKey: string | null, host: string | null) => {
|
||||
return (event: ChangeEvent<HTMLSelectElement>) => {
|
||||
const role = event.target.value as "trusted" | "read-only" | "deny";
|
||||
nodeRoleMutation.mutate({
|
||||
role,
|
||||
public_key: publicKey ?? undefined,
|
||||
host: host ?? undefined,
|
||||
});
|
||||
};
|
||||
};
|
||||
|
||||
const sortedItems = useMemo(() => {
|
||||
return [...items].sort((a, b) => {
|
||||
const aSeen = a.last_seen ? new Date(a.last_seen).getTime() : 0;
|
||||
const bSeen = b.last_seen ? new Date(b.last_seen).getTime() : 0;
|
||||
return bSeen - aSeen;
|
||||
});
|
||||
}, [items]);
|
||||
|
||||
if (!nodesQuery.data) {
|
||||
return nodesQuery.isLoading ? (
|
||||
<Section id="nodes" title="Ноды" description="Доверенные и внешние узлы">
|
||||
Загрузка…
|
||||
</Section>
|
||||
) : null;
|
||||
}
|
||||
|
||||
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">
|
||||
{sortedItems.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"
|
||||
value={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">{formatUnknown(node.notes)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
import { useMemo } from "react";
|
||||
|
||||
import { useAdminOverview } from "~/shared/services/admin";
|
||||
import { Section, Badge, InfoRow } from "../components";
|
||||
import { useAdminContext } from "../context";
|
||||
import { formatBytes, formatDate, formatUnknown, numberFormatter } from "../utils/format";
|
||||
|
||||
export const AdminOverviewPage = () => {
|
||||
const { isAuthorized, handleRequestError } = useAdminContext();
|
||||
|
||||
const overviewQuery = useAdminOverview({
|
||||
enabled: isAuthorized,
|
||||
retry: false,
|
||||
refetchInterval: 60_000,
|
||||
onError: (error) => {
|
||||
handleRequestError(error, "Не удалось загрузить обзор");
|
||||
},
|
||||
});
|
||||
|
||||
const overviewCards = useMemo(() => {
|
||||
const data = overviewQuery.data;
|
||||
if (!data) {
|
||||
return [];
|
||||
}
|
||||
const { project, content, node, ipfs, codebase, runtime } = data;
|
||||
return [
|
||||
{
|
||||
label: "Хост",
|
||||
value: project.host || "локальный",
|
||||
helper: project.name,
|
||||
},
|
||||
{
|
||||
label: "TON Master",
|
||||
value: node.ton_master,
|
||||
helper: "Платформа",
|
||||
},
|
||||
{
|
||||
label: "Service Wallet",
|
||||
value: node.service_wallet,
|
||||
helper: node.id,
|
||||
},
|
||||
{
|
||||
label: "Контент",
|
||||
value: `${numberFormatter.format(content.encrypted_total)} зашифр.`,
|
||||
helper: `${numberFormatter.format(content.derivatives_ready)} деривативов`,
|
||||
},
|
||||
{
|
||||
label: "IPFS Repo",
|
||||
value: formatBytes(Number((ipfs.repo as Record<string, unknown>)?.RepoSize ?? 0)),
|
||||
helper: "Размер репозитория",
|
||||
},
|
||||
{
|
||||
label: "Bitswap",
|
||||
value: numberFormatter.format(Number((ipfs.bitswap as Record<string, unknown>)?.Peers ?? 0)),
|
||||
helper: "Пиры",
|
||||
},
|
||||
{
|
||||
label: "Билд",
|
||||
value: codebase.commit ?? "n/a",
|
||||
helper: codebase.branch ?? "",
|
||||
},
|
||||
{
|
||||
label: "Python",
|
||||
value: runtime.python,
|
||||
helper: runtime.implementation,
|
||||
},
|
||||
];
|
||||
}, [overviewQuery.data]);
|
||||
|
||||
if (!overviewQuery.data) {
|
||||
return overviewQuery.isLoading ? (
|
||||
<Section id="overview" title="Обзор" description="Краткий срез состояния узла, окружения и служб">
|
||||
Загрузка…
|
||||
</Section>
|
||||
) : null;
|
||||
}
|
||||
|
||||
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()}
|
||||
disabled={overviewQuery.isFetching}
|
||||
>
|
||||
{overviewQuery.isFetching ? "Обновляем…" : "Обновить"}
|
||||
</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">{formatUnknown((ipfs.identity as Record<string, unknown>)?.ID)}</InfoRow>
|
||||
<InfoRow label="Agent">{formatUnknown((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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,217 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useAdminStars } from "~/shared/services/admin";
|
||||
import { Section, PaginationControls, Badge } from "../components";
|
||||
import { useAdminContext } from "../context";
|
||||
import { formatDate, formatStars, numberFormatter } from "../utils/format";
|
||||
|
||||
export const AdminStarsPage = () => {
|
||||
const { isAuthorized, handleRequestError } = useAdminContext();
|
||||
const [search, setSearch] = useState("");
|
||||
const [paidFilter, setPaidFilter] = useState<"all" | "paid" | "unpaid">("all");
|
||||
const [typeFilter, setTypeFilter] = useState("all");
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const limit = 50;
|
||||
const normalizedSearch = search.trim();
|
||||
|
||||
const starsQuery = useAdminStars(
|
||||
{
|
||||
limit,
|
||||
offset: page * limit,
|
||||
search: normalizedSearch || undefined,
|
||||
type: typeFilter !== "all" ? typeFilter : undefined,
|
||||
paid: paidFilter === "all" ? undefined : paidFilter === "paid",
|
||||
},
|
||||
{
|
||||
enabled: isAuthorized,
|
||||
keepPreviousData: true,
|
||||
refetchInterval: 60_000,
|
||||
onError: (error) => handleRequestError(error, "Не удалось загрузить платежи"),
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
}, [normalizedSearch, paidFilter, typeFilter]);
|
||||
|
||||
if (starsQuery.isLoading && !starsQuery.data) {
|
||||
return (
|
||||
<Section id="stars" title="Платежи Stars" description="Телеграм Stars счета и связанные пользователи">
|
||||
Загрузка…
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!starsQuery.data) {
|
||||
return (
|
||||
<Section id="stars" title="Платежи Stars" description="Телеграм Stars счета и связанные пользователи">
|
||||
<p className="text-sm text-slate-400">Выберите вкладку «Платежи Stars», чтобы загрузить данные.</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
const { items, stats, total } = starsQuery.data;
|
||||
const typeOptions = Object.keys(stats.by_type || {});
|
||||
|
||||
const statCards = [
|
||||
{ label: "Всего платежей", value: numberFormatter.format(stats.total) },
|
||||
{ label: "Оплачено", value: `${numberFormatter.format(stats.paid)} · ${formatStars(stats.amount_paid)}` },
|
||||
{ label: "Неоплачено", value: `${numberFormatter.format(stats.unpaid)} · ${formatStars(stats.amount_unpaid)}` },
|
||||
];
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="stars"
|
||||
title="Платежи Stars"
|
||||
description="Телеграм Stars счета и связанные пользователи"
|
||||
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={() => starsQuery.refetch()}
|
||||
disabled={starsQuery.isFetching}
|
||||
>
|
||||
{starsQuery.isFetching ? "Обновляем…" : "Обновить"}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
{statCards.map((card) => (
|
||||
<div key={card.label} className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800 shadow-inner shadow-black/40">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500">{card.label}</p>
|
||||
<p className="mt-2 text-lg font-semibold text-slate-100">{card.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-end">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Поиск</label>
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="ID, ссылка, контент"
|
||||
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-500 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 md:w-64"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Статус</label>
|
||||
<select
|
||||
value={paidFilter}
|
||||
onChange={(event) => setPaidFilter(event.target.value as "all" | "paid" | "unpaid")}
|
||||
className="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
||||
>
|
||||
<option value="all">Все</option>
|
||||
<option value="paid">Оплачено</option>
|
||||
<option value="unpaid">Не оплачено</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Тип</label>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(event) => setTypeFilter(event.target.value)}
|
||||
className="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
||||
>
|
||||
<option value="all">Все</option>
|
||||
{typeOptions.map((option) => (
|
||||
<option key={`stars-type-${option}`} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-slate-900 px-3 py-2 text-xs font-semibold text-slate-300 ring-1 ring-slate-700 transition hover:bg-slate-800"
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
setPaidFilter("all");
|
||||
setTypeFilter("all");
|
||||
}}
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 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>
|
||||
<th className="px-3 py-3">Создан</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-900/60">
|
||||
{items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={5} className="px-3 py-6 text-center text-sm text-slate-400">
|
||||
Платежей не найдено.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
items.map((invoice) => (
|
||||
<tr key={`stars-${invoice.id}`} className="hover:bg-slate-900/40">
|
||||
<td className="px-3 py-3 align-top">
|
||||
<div className="font-semibold text-slate-100">
|
||||
#{numberFormatter.format(invoice.id)} · {invoice.type ?? "—"}
|
||||
</div>
|
||||
<div className="text-xs text-slate-400 break-all">{invoice.external_id}</div>
|
||||
{invoice.invoice_url ? (
|
||||
<a
|
||||
href={invoice.invoice_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-xs text-sky-300 hover:text-sky-200"
|
||||
>
|
||||
Открыть счёт
|
||||
</a>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top">
|
||||
<div className="font-semibold text-slate-100">{formatStars(invoice.amount)}</div>
|
||||
<Badge tone={invoice.paid ? "success" : "warn"}>{invoice.status}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top">
|
||||
{invoice.user ? (
|
||||
<div className="text-xs text-slate-300">
|
||||
ID {numberFormatter.format(invoice.user.id)} · TG {numberFormatter.format(invoice.user.telegram_id)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-slate-500">—</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top">
|
||||
{invoice.content ? (
|
||||
<>
|
||||
<div className="text-xs text-slate-300">{invoice.content.title}</div>
|
||||
<div className="break-all text-[10px] uppercase tracking-wide text-slate-500">
|
||||
{invoice.content.hash}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-xs text-slate-500">—</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top text-xs text-slate-400">{formatDate(invoice.created_at)}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<PaginationControls total={total} limit={limit} page={page} onPageChange={setPage} />
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,251 @@
|
|||
import { useEffect } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import {
|
||||
useAdminCacheCleanup,
|
||||
useAdminCacheSetLimits,
|
||||
useAdminStatus,
|
||||
useAdminSyncSetLimits,
|
||||
} from "~/shared/services/admin";
|
||||
import { Section, InfoRow } from "../components";
|
||||
import { useAdminContext } from "../context";
|
||||
import { formatBytes, numberFormatter } from "../utils/format";
|
||||
|
||||
type CacheLimitsFormValues = {
|
||||
max_gb: number;
|
||||
ttl_days: number;
|
||||
};
|
||||
|
||||
type CacheFitFormValues = {
|
||||
max_gb: number;
|
||||
};
|
||||
|
||||
type SyncLimitsFormValues = {
|
||||
max_concurrent_pins: number;
|
||||
disk_low_watermark_pct: number;
|
||||
};
|
||||
|
||||
export const AdminStatusPage = () => {
|
||||
const { isAuthorized, handleRequestError, pushFlash } = useAdminContext();
|
||||
|
||||
const statusQuery = useAdminStatus({
|
||||
enabled: isAuthorized,
|
||||
refetchInterval: 30_000,
|
||||
onError: (error) => handleRequestError(error, "Не удалось загрузить статус синхронизации"),
|
||||
});
|
||||
|
||||
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(() => {
|
||||
if (!statusQuery.data) {
|
||||
return;
|
||||
}
|
||||
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,
|
||||
});
|
||||
}, [cacheFitForm, cacheLimitsForm, statusQuery.data, syncLimitsForm]);
|
||||
|
||||
const cacheLimitsMutation = useAdminCacheSetLimits({
|
||||
onSuccess: async () => {
|
||||
pushFlash({ type: "success", message: "Лимиты кэша обновлены" });
|
||||
await statusQuery.refetch();
|
||||
},
|
||||
onError: (error) => handleRequestError(error, "Не удалось сохранить лимиты кэша"),
|
||||
});
|
||||
|
||||
const cacheCleanupMutation = useAdminCacheCleanup({
|
||||
onSuccess: async (data) => {
|
||||
const removed = typeof data.removed === "number" ? `Удалено файлов: ${data.removed}` : "";
|
||||
pushFlash({ type: "success", message: `Очистка кэша выполнена. ${removed}`.trim() });
|
||||
await statusQuery.refetch();
|
||||
},
|
||||
onError: (error) => handleRequestError(error, "Ошибка очистки кэша"),
|
||||
});
|
||||
|
||||
const syncLimitsMutation = useAdminSyncSetLimits({
|
||||
onSuccess: async () => {
|
||||
pushFlash({ type: "success", message: "Параметры синхронизации обновлены" });
|
||||
await statusQuery.refetch();
|
||||
},
|
||||
onError: (error) => handleRequestError(error, "Не удалось обновить лимиты синхронизации"),
|
||||
});
|
||||
|
||||
if (!statusQuery.data) {
|
||||
return statusQuery.isLoading ? (
|
||||
<Section id="status" title="Статус & лимиты" description="IPFS, пины и лимиты">
|
||||
Загрузка…
|
||||
</Section>
|
||||
) : null;
|
||||
}
|
||||
|
||||
const { ipfs, pin_counts, derivatives, convert_backlog } = 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import { useAdminStorage } from "~/shared/services/admin";
|
||||
import { Section, Badge, InfoRow } from "../components";
|
||||
import { useAdminContext } from "../context";
|
||||
import { formatBytes, numberFormatter } from "../utils/format";
|
||||
|
||||
export const AdminStoragePage = () => {
|
||||
const { isAuthorized, handleRequestError } = useAdminContext();
|
||||
|
||||
const storageQuery = useAdminStorage({
|
||||
enabled: isAuthorized,
|
||||
refetchInterval: 60_000,
|
||||
onError: (error) => {
|
||||
handleRequestError(error, "Не удалось загрузить данные хранилища");
|
||||
},
|
||||
});
|
||||
|
||||
if (storageQuery.isLoading && !storageQuery.data) {
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,98 @@
|
|||
import { useAdminSystem } from "~/shared/services/admin";
|
||||
import { Section, InfoRow, Badge } from "../components";
|
||||
import { useAdminContext } from "../context";
|
||||
import { formatDate, formatUnknown, numberFormatter } from "../utils/format";
|
||||
|
||||
export const AdminSystemPage = () => {
|
||||
const { isAuthorized, handleRequestError } = useAdminContext();
|
||||
|
||||
const systemQuery = useAdminSystem({
|
||||
enabled: isAuthorized,
|
||||
refetchInterval: 60_000,
|
||||
onError: (error) => handleRequestError(error, "Не удалось загрузить системную информацию"),
|
||||
});
|
||||
|
||||
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}>
|
||||
{formatUnknown(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">{formatUnknown(item.value)}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-slate-500">{formatUnknown(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>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,582 @@
|
|||
import { useMemo, useState } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import {
|
||||
useAdminUploads,
|
||||
type AdminUploadsContent,
|
||||
type AdminUploadsContentFlags,
|
||||
} from "~/shared/services/admin";
|
||||
import { Section, Badge, InfoRow } from "../components";
|
||||
import { useAdminContext } from "../context";
|
||||
import { formatBytes, formatDate, numberFormatter } from "../utils/format";
|
||||
|
||||
type UploadCategory = "issues" | "processing" | "ready" | "unindexed";
|
||||
type UploadFilter = "all" | UploadCategory;
|
||||
|
||||
type UploadDecoration = {
|
||||
item: AdminUploadsContent;
|
||||
categories: Set<UploadCategory>;
|
||||
searchText: string;
|
||||
hasIssue: boolean;
|
||||
isReady: boolean;
|
||||
isProcessing: boolean;
|
||||
isUnindexed: boolean;
|
||||
flags: AdminUploadsContentFlags | null;
|
||||
};
|
||||
|
||||
const numberCompact = new Intl.NumberFormat("ru-RU", { notation: "compact" });
|
||||
|
||||
const computeFlags = (item: AdminUploadsContent): AdminUploadsContentFlags => {
|
||||
const normalize = (value: string | null | undefined) => (value ?? "").toLowerCase();
|
||||
const uploadState = normalize(item.status.upload_state);
|
||||
const conversionState = normalize(item.status.conversion_state);
|
||||
const ipfsState = normalize(item.status.ipfs_state);
|
||||
const pinState = normalize(item.ipfs?.pin_state);
|
||||
const derivativeStates = item.derivatives.map((derivative) => normalize(derivative.status));
|
||||
const statusValues = [uploadState, conversionState, ipfsState, pinState, ...derivativeStates];
|
||||
|
||||
const hasIssue =
|
||||
statusValues.some((value) => value.includes("fail") || value.includes("error") || value.includes("timeout")) ||
|
||||
item.upload_history.some((event) => Boolean(event.error)) ||
|
||||
item.derivatives.some((derivative) => Boolean(derivative.error)) ||
|
||||
Boolean(item.ipfs?.pin_error);
|
||||
|
||||
const isOnchainIndexed = item.status.onchain?.indexed ?? false;
|
||||
const isUnindexed = !isOnchainIndexed;
|
||||
|
||||
const conversionDone =
|
||||
conversionState.includes("converted") ||
|
||||
conversionState.includes("ready") ||
|
||||
derivativeStates.some((state) => state.includes("ready") || state.includes("converted") || state.includes("complete"));
|
||||
|
||||
const ipfsDone =
|
||||
ipfsState.includes("pinned") ||
|
||||
ipfsState.includes("ready") ||
|
||||
pinState.includes("pinned") ||
|
||||
pinState.includes("ready");
|
||||
|
||||
const isReady = !hasIssue && conversionDone && ipfsDone && isOnchainIndexed;
|
||||
|
||||
const hasProcessingKeywords =
|
||||
uploadState.includes("pending") ||
|
||||
uploadState.includes("process") ||
|
||||
uploadState.includes("queue") ||
|
||||
uploadState.includes("upload") ||
|
||||
conversionState.includes("pending") ||
|
||||
conversionState.includes("process") ||
|
||||
conversionState.includes("queue") ||
|
||||
conversionState.includes("convert") ||
|
||||
ipfsState.includes("pin") ||
|
||||
ipfsState.includes("sync") ||
|
||||
pinState.includes("pin") ||
|
||||
pinState.includes("sync") ||
|
||||
derivativeStates.some((state) => state.includes("pending") || state.includes("process") || state.includes("queue"));
|
||||
|
||||
const isProcessingCandidate = !isReady && !hasIssue && hasProcessingKeywords;
|
||||
const isProcessing = isProcessingCandidate || (!isReady && !hasIssue && !isProcessingCandidate);
|
||||
|
||||
return {
|
||||
issues: hasIssue,
|
||||
processing: isProcessing,
|
||||
ready: isReady,
|
||||
unindexed: isUnindexed,
|
||||
};
|
||||
};
|
||||
|
||||
const classifyUpload = (item: AdminUploadsContent): UploadDecoration => {
|
||||
const flags = item.flags ?? computeFlags(item);
|
||||
const categories = new Set<UploadCategory>();
|
||||
if (flags.issues) {
|
||||
categories.add("issues");
|
||||
}
|
||||
if (flags.processing) {
|
||||
categories.add("processing");
|
||||
}
|
||||
if (flags.ready) {
|
||||
categories.add("ready");
|
||||
}
|
||||
if (flags.unindexed) {
|
||||
categories.add("unindexed");
|
||||
}
|
||||
if (categories.size === 0) {
|
||||
if (!(item.status.onchain?.indexed ?? false)) {
|
||||
categories.add("unindexed");
|
||||
}
|
||||
categories.add("processing");
|
||||
}
|
||||
|
||||
const searchParts: Array<string | number | null | undefined> = [
|
||||
item.title,
|
||||
item.description,
|
||||
item.encrypted_cid,
|
||||
item.metadata_cid,
|
||||
item.content_hash,
|
||||
item.status.onchain?.item_address,
|
||||
item.stored?.owner_address,
|
||||
];
|
||||
if (item.stored?.user) {
|
||||
const user = item.stored.user;
|
||||
searchParts.push(user.id, user.telegram_id, user.username, user.first_name, user.last_name);
|
||||
}
|
||||
const searchText = searchParts
|
||||
.filter((value) => value !== null && value !== undefined && `${value}`.length > 0)
|
||||
.map((value) => `${value}`.toLowerCase())
|
||||
.join(" ");
|
||||
|
||||
return {
|
||||
item,
|
||||
categories,
|
||||
searchText,
|
||||
hasIssue: Boolean(flags.issues),
|
||||
isReady: Boolean(flags.ready),
|
||||
isProcessing: Boolean(flags.processing),
|
||||
isUnindexed: Boolean(flags.unindexed),
|
||||
flags: item.flags ?? flags,
|
||||
};
|
||||
};
|
||||
|
||||
const getProblemMessages = (item: AdminUploadsContent): string[] => {
|
||||
const messages: string[] = [];
|
||||
for (let index = item.upload_history.length - 1; index >= 0; index -= 1) {
|
||||
const event = item.upload_history[index];
|
||||
if (event.error) {
|
||||
messages.push(`Загрузка: ${event.error}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const derivativeError = item.derivatives.find((derivative) => Boolean(derivative.error));
|
||||
if (derivativeError?.error) {
|
||||
messages.push(`Дериватив ${derivativeError.kind}: ${derivativeError.error}`);
|
||||
}
|
||||
if (item.ipfs?.pin_error) {
|
||||
messages.push(`IPFS: ${item.ipfs.pin_error}`);
|
||||
}
|
||||
const conversionState = item.status.conversion_state;
|
||||
if (!messages.length && conversionState && conversionState.toLowerCase().includes("fail")) {
|
||||
messages.push(`Конверсия: ${conversionState}`);
|
||||
}
|
||||
const uploadState = item.status.upload_state;
|
||||
if (!messages.length && uploadState && uploadState.toLowerCase().includes("fail")) {
|
||||
messages.push(`Загрузка: ${uploadState}`);
|
||||
}
|
||||
return messages;
|
||||
};
|
||||
|
||||
const UploadCard = ({ decoration }: { decoration: UploadDecoration }) => {
|
||||
const { item, flags } = decoration;
|
||||
const problemMessages = decoration.hasIssue ? getProblemMessages(item) : [];
|
||||
const derivativeDownloads = item.links.download_derivatives ?? [];
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-slate-800 bg-slate-950/40 p-5 shadow-inner shadow-black/40">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-slate-100">{item.title || "Без названия"}</h3>
|
||||
<p className="text-xs text-slate-400">{item.description || "Описание не задано"}</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] uppercase tracking-wide text-slate-500">
|
||||
{flags?.ready ? <Badge tone="success">готово</Badge> : null}
|
||||
{flags?.issues ? <Badge tone="danger">проблемы</Badge> : null}
|
||||
{flags?.processing ? <Badge tone="warn">в обработке</Badge> : null}
|
||||
{flags?.unindexed ? <Badge tone="neutral">без индекса</Badge> : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1 text-xs text-slate-400">
|
||||
<span>{formatDate(item.created_at)}</span>
|
||||
<span>{formatDate(item.updated_at)}</span>
|
||||
{item.size.plain ? <span>{formatBytes(item.size.plain)}</span> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid gap-4 lg:grid-cols-[1.3fr_1fr]">
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl bg-slate-900/60 p-4 ring-1 ring-slate-800">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">On-chain</h4>
|
||||
<div className="mt-2 grid gap-2 text-xs text-slate-300 sm:grid-cols-2">
|
||||
<InfoRow label="Encrypted CID">
|
||||
<span className="break-all font-mono">{item.encrypted_cid}</span>
|
||||
</InfoRow>
|
||||
<InfoRow label="Metadata CID">
|
||||
<span className="break-all font-mono">{item.metadata_cid ?? "—"}</span>
|
||||
</InfoRow>
|
||||
<InfoRow label="Content hash">
|
||||
<span className="break-all font-mono">{item.content_hash ?? "—"}</span>
|
||||
</InfoRow>
|
||||
<InfoRow label="On-chain индекс">
|
||||
{item.status.onchain?.indexed ? (
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Badge tone="success">indexed</Badge>
|
||||
{item.status.onchain?.onchain_index ?? "—"}
|
||||
</span>
|
||||
) : (
|
||||
<Badge tone="warn">не опубликовано</Badge>
|
||||
)}
|
||||
</InfoRow>
|
||||
<InfoRow label="On-chain адрес">
|
||||
<span className="break-all font-mono text-xs">{item.status.onchain?.item_address ?? "—"}</span>
|
||||
</InfoRow>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-slate-900/60 p-4 ring-1 ring-slate-800">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Статусы</h4>
|
||||
<div className="mt-2 grid gap-3 text-xs text-slate-300 md:grid-cols-2">
|
||||
<div>
|
||||
<p className="font-semibold text-slate-200">Загрузка</p>
|
||||
<p>{item.status.upload_state ?? "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-200">Конверсия</p>
|
||||
<p>{item.status.conversion_state ?? "—"}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-semibold text-slate-200">IPFS</p>
|
||||
<p>{item.status.ipfs_state ?? "—"}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-slate-900/60 p-4 ring-1 ring-slate-800">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">История загрузки</h4>
|
||||
<ul className="mt-2 space-y-2 text-xs text-slate-300">
|
||||
{item.upload_history.map((event) => (
|
||||
<li key={`${item.encrypted_cid}-${event.filename}-${event.state}`} className="rounded-lg border border-slate-800 bg-slate-950/40 px-3 py-2">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<span className="font-semibold text-slate-200">{event.state}</span>
|
||||
<span className="text-[11px] text-slate-500">{formatDate(event.at)}</span>
|
||||
</div>
|
||||
{event.error ? <p className="mt-1 text-rose-300">{event.error}</p> : null}
|
||||
{event.filename ? <p className="mt-1 text-slate-400">{event.filename}</p> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{problemMessages.length > 0 ? (
|
||||
<div className="rounded-xl border border-rose-500/40 bg-rose-500/10 p-4 text-xs text-rose-100">
|
||||
<h4 className="font-semibold uppercase tracking-wide">Проблемы</h4>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{problemMessages.map((message, index) => (
|
||||
<li key={`${item.encrypted_cid}-issue-${index}`}>{message}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-xl bg-slate-900/40 p-4 ring-1 ring-slate-800">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Деривативы</h4>
|
||||
<ul className="mt-2 space-y-2 text-xs text-slate-300">
|
||||
{item.derivatives.map((derivative) => (
|
||||
<li key={`${item.encrypted_cid}-${derivative.kind}`} className="rounded-lg border border-slate-800 bg-slate-950/40 px-3 py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold text-slate-200">{derivative.kind}</span>
|
||||
<Badge tone={derivative.status === "ready" ? "success" : derivative.status === "failed" ? "danger" : "warn"}>
|
||||
{derivative.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="mt-1 space-y-1 text-[11px] text-slate-400">
|
||||
<div>Размер: {formatBytes(derivative.size_bytes)}</div>
|
||||
<div>Попытки: {derivative.attempts}</div>
|
||||
<div>Создан: {formatDate(derivative.created_at)}</div>
|
||||
<div>Обновлён: {formatDate(derivative.updated_at)}</div>
|
||||
{derivative.error ? <div className="text-rose-300">{derivative.error}</div> : null}
|
||||
{derivative.download_url ? (
|
||||
<a
|
||||
href={derivative.download_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center text-sky-300 hover:text-sky-200"
|
||||
>
|
||||
Скачать
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-slate-900/40 p-4 ring-1 ring-slate-800">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">IPFS</h4>
|
||||
{item.ipfs ? (
|
||||
<div className="mt-2 text-xs text-slate-300">
|
||||
<div>Статус: {item.ipfs.pin_state ?? "—"}</div>
|
||||
<div>Ошибка: {item.ipfs.pin_error ?? "—"}</div>
|
||||
<div>Fetched: {formatBytes(item.ipfs.bytes_fetched)}</div>
|
||||
<div>Total: {formatBytes(item.ipfs.bytes_total)}</div>
|
||||
<div className="text-slate-500">{formatDate(item.ipfs.updated_at)}</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-xs text-slate-500">Нет информации.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl bg-slate-900/40 p-4 ring-1 ring-slate-800">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Хранение</h4>
|
||||
{item.stored ? (
|
||||
<div className="mt-2 space-y-1 text-xs text-slate-300">
|
||||
<div>ID: {item.stored.stored_id ?? "—"}</div>
|
||||
<div>Владелец: {item.stored.owner_address ?? "—"}</div>
|
||||
<div>Тип: {item.stored.type ?? "—"}</div>
|
||||
{item.stored.user ? (
|
||||
<div className="text-slate-400">Пользователь #{item.stored.user.id} · TG {item.stored.user.telegram_id}</div>
|
||||
) : null}
|
||||
{item.stored.download_url ? (
|
||||
<a
|
||||
href={item.stored.download_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center gap-2 text-xs font-semibold text-sky-300 hover:text-sky-200"
|
||||
>
|
||||
Скачать оригинал
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-2 text-xs text-slate-500">В хранилище не найден.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{derivativeDownloads.length > 0 ? (
|
||||
<div className="rounded-xl bg-slate-900/40 p-4 ring-1 ring-slate-800">
|
||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Прямые ссылки</h4>
|
||||
<ul className="mt-2 space-y-1 text-xs text-slate-300">
|
||||
{derivativeDownloads.map((entry) => (
|
||||
<li key={`${item.encrypted_cid}-link-${entry.kind}`}>
|
||||
<a href={entry.url} target="_blank" rel="noreferrer" className="text-sky-300 hover:text-sky-200">
|
||||
{entry.kind} · {entry.size_bytes ? formatBytes(entry.size_bytes) : "размер неизвестен"}
|
||||
</a>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const AdminUploadsPage = () => {
|
||||
const { isAuthorized, handleRequestError } = useAdminContext();
|
||||
const [uploadsFilter, setUploadsFilter] = useState<UploadFilter>("all");
|
||||
const [uploadsSearch, setUploadsSearch] = useState("");
|
||||
|
||||
const normalizedUploadsSearch = uploadsSearch.trim();
|
||||
const hasUploadsSearch = normalizedUploadsSearch.length > 0;
|
||||
const isUploadsFilterActive = uploadsFilter !== "all";
|
||||
const uploadsScanLimit = isUploadsFilterActive || hasUploadsSearch ? 200 : 100;
|
||||
|
||||
const uploadsQuery = useAdminUploads(
|
||||
{
|
||||
filter: isUploadsFilterActive ? uploadsFilter : undefined,
|
||||
search: hasUploadsSearch ? normalizedUploadsSearch : undefined,
|
||||
limit: 40,
|
||||
scan: uploadsScanLimit,
|
||||
},
|
||||
{
|
||||
enabled: isAuthorized,
|
||||
keepPreviousData: true,
|
||||
refetchInterval: 30_000,
|
||||
onError: (error) => handleRequestError(error, "Не удалось загрузить список загрузок"),
|
||||
},
|
||||
);
|
||||
|
||||
const uploadContents = uploadsQuery.data?.contents ?? [];
|
||||
const decoratedContents = useMemo(() => uploadContents.map((item) => classifyUpload(item)), [uploadContents]);
|
||||
|
||||
if (uploadsQuery.isLoading && !uploadsQuery.data) {
|
||||
return (
|
||||
<Section id="uploads" title="Загрузки" description="Мониторинг загрузок и конвертации">
|
||||
Загрузка…
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!uploadsQuery.data) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { states, total, recent, matching_total: matchingTotalRaw, category_totals: categoryTotalsRaw } = uploadsQuery.data;
|
||||
|
||||
const categoryTotals = categoryTotalsRaw ?? {};
|
||||
const categoryCounts: Record<UploadCategory, number> = {
|
||||
issues: categoryTotals.issues ?? 0,
|
||||
processing: categoryTotals.processing ?? 0,
|
||||
ready: categoryTotals.ready ?? 0,
|
||||
unindexed: categoryTotals.unindexed ?? 0,
|
||||
};
|
||||
|
||||
const searchNeedle = normalizedUploadsSearch.toLowerCase();
|
||||
const filteredEntries = decoratedContents.filter((entry) => {
|
||||
if (uploadsFilter !== "all" && !entry.categories.has(uploadsFilter)) {
|
||||
return false;
|
||||
}
|
||||
if (searchNeedle && !entry.searchText.includes(searchNeedle)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
const filteredContents = filteredEntries.map((entry) => entry.item);
|
||||
const totalMatches = typeof matchingTotalRaw === "number" ? matchingTotalRaw : filteredEntries.length;
|
||||
const filtersActive = isUploadsFilterActive || hasUploadsSearch;
|
||||
const noMatches = totalMatches === 0;
|
||||
const issueEntries = filteredEntries.filter((entry) => entry.categories.has("issues")).slice(0, 3);
|
||||
const filterOptions: Array<{ id: UploadFilter; label: string; count: number }> = [
|
||||
{ id: "all", label: "Все", count: totalMatches },
|
||||
{ id: "issues", label: "Ошибки", count: categoryCounts.issues },
|
||||
{ id: "processing", label: "В обработке", count: categoryCounts.processing },
|
||||
{ id: "ready", label: "Готово", count: categoryCounts.ready },
|
||||
{ id: "unindexed", label: "Без on-chain", count: categoryCounts.unindexed },
|
||||
];
|
||||
|
||||
const handleResetFilters = () => {
|
||||
setUploadsFilter("all");
|
||||
setUploadsSearch("");
|
||||
};
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="uploads"
|
||||
title="Загрузки"
|
||||
description="Полная картина обработки контента: цепочка загрузки, конверсия, IPFS и публикация"
|
||||
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()}
|
||||
disabled={uploadsQuery.isFetching}
|
||||
>
|
||||
{uploadsQuery.isFetching ? "Обновляем…" : "Обновить"}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{Object.entries(states ?? {}).map(([key, value]) => (
|
||||
<div key={key} className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500">{key}</p>
|
||||
<p className="mt-2 text-lg font-semibold text-slate-100">{numberFormatter.format(value)}</p>
|
||||
</div>
|
||||
))}
|
||||
<div className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500">Всего</p>
|
||||
<p className="mt-2 text-lg font-semibold text-slate-100">{numberFormatter.format(total)}</p>
|
||||
<p className="mt-1 text-xs text-slate-500">Отфильтровано: {numberFormatter.format(filteredContents.length)}</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500">Активные задачи</p>
|
||||
<p className="mt-2 text-lg font-semibold text-slate-100">{numberCompact.format(categoryCounts.processing)}</p>
|
||||
<p className="mt-1 text-xs text-slate-500">{numberFormatter.format(categoryCounts.issues)} с ошибками</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{filterOptions.map((option) => {
|
||||
const isActive = option.id === uploadsFilter;
|
||||
return (
|
||||
<button
|
||||
key={`upload-filter-${option.id}`}
|
||||
type="button"
|
||||
onClick={() => setUploadsFilter(option.id)}
|
||||
className={clsx(
|
||||
"inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold transition",
|
||||
isActive
|
||||
? "border-sky-500 bg-sky-500/20 text-sky-100 shadow-inner shadow-sky-500/20"
|
||||
: "border-slate-700 text-slate-300 hover:border-slate-500 hover:text-slate-100",
|
||||
)}
|
||||
>
|
||||
{option.label}
|
||||
<span className="rounded-full bg-slate-900/60 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-slate-300">
|
||||
{numberFormatter.format(option.count)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{filtersActive ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleResetFilters}
|
||||
className="inline-flex items-center rounded-full border border-slate-700 px-3 py-1 text-xs font-semibold text-slate-300 transition hover:border-slate-500 hover:text-slate-100"
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="relative w-full md:w-72">
|
||||
<input
|
||||
type="search"
|
||||
value={uploadsSearch}
|
||||
onChange={(event) => setUploadsSearch(event.target.value)}
|
||||
placeholder="Название, CID, пользователь…"
|
||||
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-500 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{issueEntries.length > 0 ? (
|
||||
<div className="rounded-xl border border-amber-500/40 bg-amber-500/10 p-4 text-xs text-amber-100">
|
||||
<h3 className="text-sm font-semibold text-amber-50">Последние проблемы</h3>
|
||||
<ul className="mt-2 space-y-1">
|
||||
{issueEntries.map((entry) => (
|
||||
<li key={`issue-${entry.item.encrypted_cid}`} className="flex items-center justify-between gap-2">
|
||||
<span className="truncate text-sm text-amber-50">{entry.item.title || entry.item.encrypted_cid}</span>
|
||||
<span className="text-[11px] text-amber-200">{formatDate(entry.item.updated_at)}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{noMatches ? (
|
||||
<div className="rounded-xl border border-slate-800 bg-slate-950/40 p-6 text-sm text-slate-400">
|
||||
Под подходящие условия не найдено загрузок.
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-5">
|
||||
{filteredEntries.map((entry) => (
|
||||
<UploadCard key={entry.item.encrypted_cid} decoration={entry} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl bg-slate-950/40 p-4 ring-1 ring-slate-800">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-400">Последние события</h3>
|
||||
<div className="mt-2 overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-slate-800 text-left text-xs">
|
||||
<thead className="bg-slate-950/70 text-[11px] uppercase tracking-wide text-slate-400">
|
||||
<tr>
|
||||
<th className="px-3 py-2">ID</th>
|
||||
<th className="px-3 py-2">Файл</th>
|
||||
<th className="px-3 py-2">Размер</th>
|
||||
<th className="px-3 py-2">Состояние</th>
|
||||
<th className="px-3 py-2">CID</th>
|
||||
<th className="px-3 py-2">Ошибка</th>
|
||||
<th className="px-3 py-2">Обновлено</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-[10px] text-slate-400">{task.id}</td>
|
||||
<td className="px-3 py-2">{task.filename ?? "—"}</td>
|
||||
<td className="px-3 py-2">{formatBytes(task.size_bytes)}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge tone={task.state === "done" ? "success" : task.state === "failed" ? "danger" : "warn"}>
|
||||
{task.state}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 font-mono text-[10px] text-slate-500">{task.encrypted_cid ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-rose-200">{task.error ?? "—"}</td>
|
||||
<td className="px-3 py-2 text-slate-400">{formatDate(task.updated_at)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,194 @@
|
|||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useAdminUsers } from "~/shared/services/admin";
|
||||
import { Section, PaginationControls } from "../components";
|
||||
import { useAdminContext } from "../context";
|
||||
import { formatDate, formatStars, numberFormatter } from "../utils/format";
|
||||
|
||||
export const AdminUsersPage = () => {
|
||||
const { isAuthorized, handleRequestError } = useAdminContext();
|
||||
const [search, setSearch] = useState("");
|
||||
const [page, setPage] = useState(0);
|
||||
|
||||
const limit = 50;
|
||||
const normalizedSearch = search.trim();
|
||||
|
||||
const usersQuery = useAdminUsers(
|
||||
{
|
||||
limit,
|
||||
offset: page * limit,
|
||||
search: normalizedSearch || undefined,
|
||||
},
|
||||
{
|
||||
enabled: isAuthorized,
|
||||
keepPreviousData: true,
|
||||
refetchInterval: 60_000,
|
||||
onError: (error) => handleRequestError(error, "Не удалось загрузить пользователей"),
|
||||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
}, [normalizedSearch]);
|
||||
|
||||
if (usersQuery.isLoading && !usersQuery.data) {
|
||||
return (
|
||||
<Section id="users" title="Пользователи" description="Мониторинг пользователей, кошельков и активности">
|
||||
Загрузка…
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
if (!usersQuery.data) {
|
||||
return (
|
||||
<Section id="users" title="Пользователи" description="Мониторинг пользователей, кошельков и активности">
|
||||
<p className="text-sm text-slate-400">Выберите вкладку «Пользователи», чтобы загрузить данные.</p>
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
|
||||
const { items, summary, total } = usersQuery.data;
|
||||
const summaryCards = [
|
||||
{
|
||||
label: "Всего пользователей",
|
||||
value: numberFormatter.format(total),
|
||||
helper: `На странице: ${numberFormatter.format(summary.users_returned)}`,
|
||||
},
|
||||
{
|
||||
label: "Кошельки (активные/всего)",
|
||||
value: `${numberFormatter.format(summary.wallets_active)} / ${numberFormatter.format(summary.wallets_total)}`,
|
||||
},
|
||||
{
|
||||
label: "Оплачено Stars",
|
||||
value: formatStars(summary.stars_amount_paid),
|
||||
helper: `${numberFormatter.format(summary.stars_paid)} платежей`,
|
||||
},
|
||||
{
|
||||
label: "Неоплачено",
|
||||
value: formatStars(summary.stars_amount_unpaid),
|
||||
helper: `${numberFormatter.format(summary.stars_unpaid)} счетов`,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Section
|
||||
id="users"
|
||||
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={() => usersQuery.refetch()}
|
||||
disabled={usersQuery.isFetching}
|
||||
>
|
||||
{usersQuery.isFetching ? "Обновляем…" : "Обновить"}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{summaryCards.map((card) => (
|
||||
<div key={card.label} className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800 shadow-inner shadow-black/40">
|
||||
<p className="text-xs uppercase tracking-wide text-slate-500">{card.label}</p>
|
||||
<p className="mt-2 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="mt-6 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Поиск</label>
|
||||
<input
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
placeholder="ID, Telegram, username, адрес"
|
||||
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-500 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 md:w-72"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-lg bg-slate-900 px-3 py-2 text-xs font-semibold text-slate-300 ring-1 ring-slate-700 transition hover:bg-slate-800"
|
||||
onClick={() => setSearch("")}
|
||||
>
|
||||
Сбросить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 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">Stars</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.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-3 py-6 text-center text-sm text-slate-400">
|
||||
Нет пользователей, удовлетворяющих условиям.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
items.map((user) => (
|
||||
<tr key={`admin-user-${user.id}`} className="hover:bg-slate-900/40">
|
||||
<td className="px-3 py-3 align-top">
|
||||
<div className="font-semibold text-slate-100">{user.username ? `@${user.username}` : "Без username"}</div>
|
||||
<div className="text-xs text-slate-400">
|
||||
ID {numberFormatter.format(user.id)} · TG {numberFormatter.format(user.telegram_id)}
|
||||
</div>
|
||||
{user.first_name || user.last_name ? (
|
||||
<div className="text-xs text-slate-500">
|
||||
{(user.first_name ?? "")} {(user.last_name ?? "")}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top">
|
||||
<div className="text-xs text-slate-400">
|
||||
Активных {numberFormatter.format(user.wallets.active_count)} / всего{" "}
|
||||
{numberFormatter.format(user.wallets.total_count)}
|
||||
</div>
|
||||
<div className="mt-1 break-all text-xs text-slate-300">{user.wallets.primary_address ?? "—"}</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top">
|
||||
<div className="font-semibold text-slate-100">{formatStars(user.stars.amount_total)}</div>
|
||||
<div className="text-xs text-slate-400">Оплачено: {formatStars(user.stars.amount_paid)}</div>
|
||||
<div className="text-xs text-slate-400">Неоплачено: {formatStars(user.stars.amount_unpaid)}</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top">
|
||||
<div className="text-xs text-slate-300">Всего {numberFormatter.format(user.licenses.total)}</div>
|
||||
<div className="text-xs text-slate-300">Активных {numberFormatter.format(user.licenses.active)}</div>
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top">
|
||||
{user.ip_activity.last ? (
|
||||
<div className="text-xs text-slate-300">
|
||||
{user.ip_activity.last.ip ?? "—"} · {formatDate(user.ip_activity.last.seen_at)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-slate-500">Нет данных</div>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-3 align-top text-xs text-slate-400">
|
||||
<div>Создан: {formatDate(user.created_at)}</div>
|
||||
<div>Последний вход: {formatDate(user.last_use)}</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<PaginationControls total={total} limit={limit} page={page} onPageChange={setPage} />
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
};
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
export { AdminOverviewPage } from "./Overview";
|
||||
export { AdminStoragePage } from "./Storage";
|
||||
export { AdminUploadsPage } from "./Uploads";
|
||||
export { AdminUsersPage } from "./Users";
|
||||
export { AdminLicensesPage } from "./Licenses";
|
||||
export { AdminStarsPage } from "./Stars";
|
||||
export { AdminSystemPage } from "./System";
|
||||
export { AdminBlockchainPage } from "./Blockchain";
|
||||
export { AdminNodesPage } from "./Nodes";
|
||||
export { AdminStatusPage } from "./Status";
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
export type FlashMessage = {
|
||||
type: "success" | "error" | "info";
|
||||
message: string;
|
||||
};
|
||||
|
||||
export type AuthState = "checking" | "authorized" | "unauthorized";
|
||||
|
||||
export type AdminContextValue = {
|
||||
authState: AuthState;
|
||||
isAuthorized: boolean;
|
||||
pushFlash: (message: FlashMessage) => void;
|
||||
clearFlash: () => void;
|
||||
handleRequestError: (error: unknown, fallbackMessage: string) => void;
|
||||
invalidateAll: () => Promise<void>;
|
||||
};
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
const numberFormatter = new Intl.NumberFormat("ru-RU");
|
||||
|
||||
const dateTimeFormatter = new Intl.DateTimeFormat("ru-RU", {
|
||||
dateStyle: "short",
|
||||
timeStyle: "medium",
|
||||
});
|
||||
|
||||
export 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;
|
||||
}
|
||||
const precision = value >= 10 || value < 0.1 ? 0 : 1;
|
||||
return `${value.toFixed(precision)} ${units[unitIndex]}`;
|
||||
};
|
||||
|
||||
export const formatStars = (input?: number | null) => {
|
||||
const value = Number(input ?? 0);
|
||||
return `${numberFormatter.format(value)} ⭑`;
|
||||
};
|
||||
|
||||
export const formatDate = (iso?: string | null) => {
|
||||
if (!iso) {
|
||||
return "—";
|
||||
}
|
||||
const date = new Date(iso);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return iso;
|
||||
}
|
||||
return dateTimeFormatter.format(date);
|
||||
};
|
||||
|
||||
export const formatUnknown = (value: unknown): string => {
|
||||
if (value === null || value === undefined) {
|
||||
return "—";
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
return value || "—";
|
||||
}
|
||||
if (typeof value === "number" || typeof value === "bigint") {
|
||||
return numberFormatter.format(Number(value));
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
return value ? "Да" : "Нет";
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return formatDate(value.toISOString());
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(value);
|
||||
} catch (error) {
|
||||
return String(value);
|
||||
}
|
||||
};
|
||||
|
||||
export { numberFormatter, dateTimeFormatter };
|
||||
Loading…
Reference in New Issue