Compare commits
5 Commits
| Author | SHA1 | Date |
|---|---|---|
|
|
37d6eda1cf | |
|
|
6e54471102 | |
|
|
5ef8c35ca8 | |
|
|
61b50df864 | |
|
|
3b31c4e6cb |
|
|
@ -7,10 +7,10 @@ This template provides a minimal setup to get React working in Vite with HMR and
|
||||||
Copy `.env.example` to `.env` (or provide the same variables through your deployment system) and adjust the URLs if needed. All frontend traffic (админка, загрузка, просмотр) завязан на `VITE_API_BASE_URL`, поэтому указывайте полный путь до `/api/v1` нужной ноды.
|
Copy `.env.example` to `.env` (or provide the same variables through your deployment system) and adjust the URLs if needed. All frontend traffic (админка, загрузка, просмотр) завязан на `VITE_API_BASE_URL`, поэтому указывайте полный путь до `/api/v1` нужной ноды.
|
||||||
|
|
||||||
```
|
```
|
||||||
VITE_API_BASE_URL=https://my-public-node-8.projscale.dev/api/v1
|
VITE_API_BASE_URL=https://my-public-node-103.projscale.dev/api/v1
|
||||||
```
|
```
|
||||||
|
|
||||||
При таком значении фронт автоматически отправляет tus-запросы на `https://my-public-node-8.projscale.dev/tus/files`, а прогрессивные загрузки (обложки, метаданные) — на `https://my-public-node-8.projscale.dev/api/v1.5/storage`.
|
При таком значении фронт автоматически отправляет tus-запросы на `https://my-public-node-103.projscale.dev/tus/files`, а прогрессивные загрузки (обложки, метаданные) — на `https://my-public-node-103.projscale.dev/api/v1.5/storage`.
|
||||||
|
|
||||||
Currently, two official plugins are available:
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export const Providers = ({ children }: ProvidersProps) => {
|
||||||
<WebAppProvider options={{ smoothButtonsTransition: true }}>
|
<WebAppProvider options={{ smoothButtonsTransition: true }}>
|
||||||
<TonConnectUIProvider
|
<TonConnectUIProvider
|
||||||
manifestUrl={
|
manifestUrl={
|
||||||
"https://my-public-node-8.projscale.dev/api/tonconnect-manifest.json"
|
"https://my-public-node-103.projscale.dev/api/tonconnect-manifest.json"
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,15 @@ import {
|
||||||
AdminStoragePage,
|
AdminStoragePage,
|
||||||
AdminUploadsPage,
|
AdminUploadsPage,
|
||||||
AdminUsersPage,
|
AdminUsersPage,
|
||||||
|
AdminEventsPage,
|
||||||
AdminLicensesPage,
|
AdminLicensesPage,
|
||||||
AdminStarsPage,
|
AdminStarsPage,
|
||||||
AdminSystemPage,
|
AdminSystemPage,
|
||||||
AdminBlockchainPage,
|
AdminBlockchainPage,
|
||||||
AdminNodesPage,
|
AdminNodesPage,
|
||||||
AdminStatusPage,
|
AdminStatusPage,
|
||||||
|
AdminNetworkPage,
|
||||||
|
AdminNetworkSettingsPage,
|
||||||
} from "~/pages/admin/sections";
|
} from "~/pages/admin/sections";
|
||||||
import { ProtectedLayout } from "./protected-layout";
|
import { ProtectedLayout } from "./protected-layout";
|
||||||
|
|
||||||
|
|
@ -50,6 +53,7 @@ const router = createBrowserRouter([
|
||||||
{ path: "overview", element: <AdminOverviewPage /> },
|
{ path: "overview", element: <AdminOverviewPage /> },
|
||||||
{ path: "storage", element: <AdminStoragePage /> },
|
{ path: "storage", element: <AdminStoragePage /> },
|
||||||
{ path: "uploads", element: <AdminUploadsPage /> },
|
{ path: "uploads", element: <AdminUploadsPage /> },
|
||||||
|
{ path: "events", element: <AdminEventsPage /> },
|
||||||
{ path: "users", element: <AdminUsersPage /> },
|
{ path: "users", element: <AdminUsersPage /> },
|
||||||
{ path: "licenses", element: <AdminLicensesPage /> },
|
{ path: "licenses", element: <AdminLicensesPage /> },
|
||||||
{ path: "stars", element: <AdminStarsPage /> },
|
{ path: "stars", element: <AdminStarsPage /> },
|
||||||
|
|
@ -57,6 +61,8 @@ const router = createBrowserRouter([
|
||||||
{ path: "blockchain", element: <AdminBlockchainPage /> },
|
{ path: "blockchain", element: <AdminBlockchainPage /> },
|
||||||
{ path: "nodes", element: <AdminNodesPage /> },
|
{ path: "nodes", element: <AdminNodesPage /> },
|
||||||
{ path: "status", element: <AdminStatusPage /> },
|
{ path: "status", element: <AdminStatusPage /> },
|
||||||
|
{ path: "network", element: <AdminNetworkPage /> },
|
||||||
|
{ path: "network-settings", element: <AdminNetworkSettingsPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useCallback } from "react";
|
import { ReactNode, useCallback } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
|
||||||
import { useAdminContext } from "../context";
|
import { useAdminContext } from "../context";
|
||||||
|
|
@ -8,6 +8,7 @@ type CopyButtonProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
"aria-label"?: string;
|
"aria-label"?: string;
|
||||||
successMessage?: string;
|
successMessage?: string;
|
||||||
|
children?: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
const copyFallback = (text: string) => {
|
const copyFallback = (text: string) => {
|
||||||
|
|
@ -29,7 +30,7 @@ const copyFallback = (text: string) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const CopyButton = ({ value, className, "aria-label": ariaLabel, successMessage }: CopyButtonProps) => {
|
export const CopyButton = ({ value, className, "aria-label": ariaLabel, successMessage, children }: CopyButtonProps) => {
|
||||||
const { pushFlash } = useAdminContext();
|
const { pushFlash } = useAdminContext();
|
||||||
|
|
||||||
const handleCopy = useCallback(async () => {
|
const handleCopy = useCallback(async () => {
|
||||||
|
|
@ -59,12 +60,14 @@ export const CopyButton = ({ value, className, "aria-label": ariaLabel, successM
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"inline-flex h-7 w-7 shrink-0 items-center justify-center rounded-md border border-slate-700 bg-slate-900 text-slate-300 transition hover:border-sky-500 hover:text-white focus:outline-none focus:ring-2 focus:ring-sky-500/40",
|
"inline-flex shrink-0 items-center justify-center rounded-md border border-slate-700 bg-slate-900 text-slate-300 transition hover:border-sky-500 hover:text-white focus:outline-none focus:ring-2 focus:ring-sky-500/40",
|
||||||
|
children ? "h-7 px-3" : "h-7 w-7",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
aria-label={ariaLabel ?? "Скопировать значение"}
|
aria-label={ariaLabel ?? "Скопировать значение"}
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
>
|
>
|
||||||
|
{children ?? (
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
className="h-4 w-4"
|
className="h-4 w-4"
|
||||||
|
|
@ -78,6 +81,7 @@ export const CopyButton = ({ value, className, "aria-label": ariaLabel, successM
|
||||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||||
</svg>
|
</svg>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,13 +4,16 @@ export type AdminSectionId =
|
||||||
| "overview"
|
| "overview"
|
||||||
| "storage"
|
| "storage"
|
||||||
| "uploads"
|
| "uploads"
|
||||||
|
| "events"
|
||||||
| "users"
|
| "users"
|
||||||
| "licenses"
|
| "licenses"
|
||||||
| "stars"
|
| "stars"
|
||||||
| "system"
|
| "system"
|
||||||
| "blockchain"
|
| "blockchain"
|
||||||
| "nodes"
|
| "nodes"
|
||||||
| "status";
|
| "status"
|
||||||
|
| "network"
|
||||||
|
| "network-settings";
|
||||||
|
|
||||||
export type AdminSection = {
|
export type AdminSection = {
|
||||||
id: AdminSectionId;
|
id: AdminSectionId;
|
||||||
|
|
@ -24,6 +27,7 @@ export const ADMIN_SECTIONS: AdminSection[] = [
|
||||||
{ id: "overview", label: "Обзор", description: "Краткий срез состояния узла, окружения и служб", path: "overview" },
|
{ id: "overview", label: "Обзор", description: "Краткий срез состояния узла, окружения и служб", path: "overview" },
|
||||||
{ id: "storage", label: "Хранилище", description: "Загруженность диска и директории контента", path: "storage" },
|
{ id: "storage", label: "Хранилище", description: "Загруженность диска и директории контента", path: "storage" },
|
||||||
{ id: "uploads", label: "Загрузки", description: "Отслеживание статуса загрузок и деривативов", path: "uploads" },
|
{ id: "uploads", label: "Загрузки", description: "Отслеживание статуса загрузок и деривативов", path: "uploads" },
|
||||||
|
{ id: "events", label: "События", description: "Журнал действий ноды и сети", path: "events" },
|
||||||
{ id: "users", label: "Пользователи", description: "Мониторинг пользователей, кошельков и активности", path: "users" },
|
{ id: "users", label: "Пользователи", description: "Мониторинг пользователей, кошельков и активности", path: "users" },
|
||||||
{ id: "licenses", label: "Лицензии", description: "Статус лицензий и фильтрация по типам", path: "licenses" },
|
{ id: "licenses", label: "Лицензии", description: "Статус лицензий и фильтрация по типам", path: "licenses" },
|
||||||
{ id: "stars", label: "Платежи Stars", description: "Управление счетами и анализ платежей", path: "stars" },
|
{ id: "stars", label: "Платежи Stars", description: "Управление счетами и анализ платежей", path: "stars" },
|
||||||
|
|
@ -31,6 +35,8 @@ export const ADMIN_SECTIONS: AdminSection[] = [
|
||||||
{ id: "blockchain", label: "Блокчейн", description: "История задач и метрики блокчейн-интеграции", path: "blockchain" },
|
{ id: "blockchain", label: "Блокчейн", description: "История задач и метрики блокчейн-интеграции", path: "blockchain" },
|
||||||
{ id: "nodes", label: "Ноды", description: "Роли, версии и последнее появление узлов", path: "nodes" },
|
{ id: "nodes", label: "Ноды", description: "Роли, версии и последнее появление узлов", path: "nodes" },
|
||||||
{ id: "status", label: "Статус & лимиты", description: "IPFS, очереди и лимиты синхронизации", path: "status" },
|
{ id: "status", label: "Статус & лимиты", description: "IPFS, очереди и лимиты синхронизации", path: "status" },
|
||||||
|
{ id: "network", label: "Состояние сети", description: "Мониторинг децентрализованного слоя и репликаций", path: "network" },
|
||||||
|
{ id: "network-settings", label: "Сеть → Настройки", description: "Интервалы heartbeat/gossip и бэк-офф", path: "network-settings" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DEFAULT_ADMIN_SECTION = ADMIN_SECTIONS[0];
|
export const DEFAULT_ADMIN_SECTION = ADMIN_SECTIONS[0];
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,307 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
|
import { Routes } from "~/app/router/constants";
|
||||||
|
import { useAdminEvents } from "~/shared/services/admin";
|
||||||
|
import { Section, PaginationControls, CopyButton, Badge } from "../components";
|
||||||
|
import { useAdminContext } from "../context";
|
||||||
|
import { formatDate, numberFormatter } from "../utils/format";
|
||||||
|
|
||||||
|
const EVENT_TYPE_LABELS: Record<string, string> = {
|
||||||
|
content_uploaded: "Загрузка контента",
|
||||||
|
content_indexed: "Индексация контента",
|
||||||
|
stars_payment: "Платёж Stars",
|
||||||
|
node_registered: "Регистрация ноды",
|
||||||
|
user_role_changed: "Изменение ролей",
|
||||||
|
};
|
||||||
|
|
||||||
|
const EVENT_STATUS_TONES: Record<string, "success" | "warn" | "danger" | "neutral"> = {
|
||||||
|
local: "neutral",
|
||||||
|
recorded: "neutral",
|
||||||
|
processing: "warn",
|
||||||
|
applied: "success",
|
||||||
|
failed: "danger",
|
||||||
|
};
|
||||||
|
|
||||||
|
const linkLabels: Record<string, string> = {
|
||||||
|
admin_uploads: "К загрузкам",
|
||||||
|
content_view: "Просмотр контента",
|
||||||
|
admin_stars: "К платежам",
|
||||||
|
admin_user: "К пользователю",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AdminEventsPage = () => {
|
||||||
|
const { isAuthorized, handleRequestError } = useAdminContext();
|
||||||
|
const [search, setSearch] = useState("");
|
||||||
|
const [typeFilter, setTypeFilter] = useState<string | null>(null);
|
||||||
|
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||||
|
const [originFilter, setOriginFilter] = useState<string | null>(null);
|
||||||
|
const [page, setPage] = useState(0);
|
||||||
|
const limit = 50;
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const normalizedSearch = search.trim() || null;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(0);
|
||||||
|
}, [normalizedSearch, typeFilter, statusFilter, originFilter]);
|
||||||
|
|
||||||
|
const eventsQuery = useAdminEvents(
|
||||||
|
{
|
||||||
|
limit,
|
||||||
|
offset: page * limit,
|
||||||
|
search: normalizedSearch || undefined,
|
||||||
|
type: typeFilter || undefined,
|
||||||
|
status: statusFilter || undefined,
|
||||||
|
origin: originFilter || undefined,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
enabled: isAuthorized,
|
||||||
|
keepPreviousData: true,
|
||||||
|
refetchInterval: 60_000,
|
||||||
|
onError: (error) => handleRequestError(error, "Не удалось загрузить события"),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
const availableFilters = eventsQuery.data?.available_filters ?? {
|
||||||
|
types: {},
|
||||||
|
statuses: {},
|
||||||
|
origins: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderLink = (key: string, url: string | null) => {
|
||||||
|
if (!url) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const label = linkLabels[key] ?? key;
|
||||||
|
if (url.startsWith("http")) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={`${key}-${url}`}
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="rounded-lg border border-sky-500/40 px-3 py-1 text-[11px] font-semibold text-sky-100 transition hover:border-sky-400 hover:text-white"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const normalized = url.startsWith("/") ? url.slice(1) : url;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={`${key}-${url}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => navigate(`${Routes.Admin}/${normalized}`)}
|
||||||
|
className="rounded-lg border border-slate-700 px-3 py-1 text-[11px] font-semibold text-slate-200 transition hover:border-slate-500 hover:text-white"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const events = eventsQuery.data?.items ?? [];
|
||||||
|
const total = eventsQuery.data?.total ?? 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Section
|
||||||
|
id="events"
|
||||||
|
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={() => eventsQuery.refetch()}
|
||||||
|
disabled={eventsQuery.isFetching}
|
||||||
|
>
|
||||||
|
{eventsQuery.isFetching ? "Обновляем…" : "Обновить"}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div className="overflow-hidden 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">Всего событий</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(events.length)}</p>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden 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">Типы</p>
|
||||||
|
<p className="mt-2 text-lg font-semibold text-slate-100">{Object.keys(availableFilters.types).length}</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">Фильтр по типу помогает сузить список</p>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden 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">Статусы</p>
|
||||||
|
<p className="mt-2 text-lg font-semibold text-slate-100">{Object.keys(availableFilters.statuses).length}</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">Например, только требующие действий</p>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-hidden 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">Источники</p>
|
||||||
|
<p className="mt-2 text-lg font-semibold text-slate-100">{Object.keys(availableFilters.origins).length}</p>
|
||||||
|
<p className="mt-1 text-xs text-slate-500">Отслеживайте trusted-нод</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-3 md:grid-cols-4">
|
||||||
|
<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="UID, CID, invoice, json"
|
||||||
|
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 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 || null)}
|
||||||
|
className="w-full 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="">Все типы</option>
|
||||||
|
{Object.entries(availableFilters.types).map(([type, count]) => (
|
||||||
|
<option key={`event-type-${type}`} value={type}>
|
||||||
|
{EVENT_TYPE_LABELS[type] ?? type} — {numberFormatter.format(count)}
|
||||||
|
</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={statusFilter ?? ""}
|
||||||
|
onChange={(event) => setStatusFilter(event.target.value || null)}
|
||||||
|
className="w-full 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="">Все статусы</option>
|
||||||
|
{Object.entries(availableFilters.statuses).map(([status, count]) => (
|
||||||
|
<option key={`event-status-${status}`} value={status}>
|
||||||
|
{status} — {numberFormatter.format(count)}
|
||||||
|
</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={originFilter ?? ""}
|
||||||
|
onChange={(event) => setOriginFilter(event.target.value || null)}
|
||||||
|
className="w-full 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="">Все источники</option>
|
||||||
|
{Object.entries(availableFilters.origins).map(([origin, count]) => (
|
||||||
|
<option key={`event-origin-${origin}`} value={origin}>
|
||||||
|
{origin} — {numberFormatter.format(count)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex flex-wrap 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("");
|
||||||
|
setTypeFilter(null);
|
||||||
|
setStatusFilter(null);
|
||||||
|
setOriginFilter(null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Сбросить фильтры
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{eventsQuery.isLoading && !eventsQuery.data ? (
|
||||||
|
<div className="mt-6 rounded-2xl border border-slate-800 bg-slate-950/40 px-4 py-6 text-center text-sm text-slate-400">
|
||||||
|
Загружаем события…
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="mt-6 grid gap-4">
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<div className="rounded-2xl border border-slate-800 bg-slate-950/40 px-4 py-6 text-center text-sm text-slate-400">
|
||||||
|
События не найдены.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
events.map((event) => {
|
||||||
|
const typeLabel = EVENT_TYPE_LABELS[event.event_type] ?? event.event_type;
|
||||||
|
const statusTone = EVENT_STATUS_TONES[event.status] ?? 'neutral';
|
||||||
|
const statusBadgeTone: "success" | "warn" | "danger" | "neutral" = statusTone;
|
||||||
|
const linkEntries = Object.entries(event.links ?? {});
|
||||||
|
return (
|
||||||
|
<div key={`event-${event.id}`} className="rounded-2xl border border-slate-800 bg-slate-950/50 p-5 shadow-inner shadow-black/40">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-sm text-slate-200">
|
||||||
|
<span className="font-semibold">#{numberFormatter.format(event.seq)}</span>
|
||||||
|
<Badge tone="neutral">{typeLabel}</Badge>
|
||||||
|
<Badge tone={statusBadgeTone}>{event.status}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-slate-400">
|
||||||
|
<span>UID: {event.uid}</span>
|
||||||
|
<CopyButton
|
||||||
|
value={event.uid}
|
||||||
|
aria-label="Скопировать UID"
|
||||||
|
successMessage="UID скопирован"
|
||||||
|
className="h-6 w-6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-400 text-right">
|
||||||
|
<div>Создано: {formatDate(event.created_at)}</div>
|
||||||
|
<div>Получено: {formatDate(event.received_at)}</div>
|
||||||
|
<div>Применено: {formatDate(event.applied_at)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 grid gap-2 text-xs text-slate-300 md:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Источник</span>
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-2 break-all text-[11px] text-slate-300">
|
||||||
|
<span>{event.origin_public_key ?? "—"}</span>
|
||||||
|
{event.origin_public_key ? (
|
||||||
|
<CopyButton
|
||||||
|
value={event.origin_public_key}
|
||||||
|
aria-label="Скопировать ключ"
|
||||||
|
successMessage="Публичный ключ скопирован"
|
||||||
|
className="h-6 w-6"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<div className="text-[11px] text-slate-500">{event.origin_host ?? "—"}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-slate-500">Ссылки</span>
|
||||||
|
{linkEntries.length === 0 ? (
|
||||||
|
<div className="mt-1 text-[11px] text-slate-500">Нет прямых ссылок</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-1 flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
{linkEntries.map(([key, url]) => renderLink(key, url))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<details className="mt-4 rounded-xl border border-slate-800 bg-slate-950/40">
|
||||||
|
<summary className="cursor-pointer px-4 py-2 text-xs font-semibold text-slate-200">Показать payload</summary>
|
||||||
|
<pre className="max-h-60 overflow-auto px-4 py-3 text-[11px] leading-relaxed text-slate-200">
|
||||||
|
{JSON.stringify(event.payload ?? {}, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<PaginationControls
|
||||||
|
total={total}
|
||||||
|
limit={limit}
|
||||||
|
page={page}
|
||||||
|
onPageChange={(next) => setPage(next)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,329 @@
|
||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
import { useQuery } from "react-query";
|
||||||
|
import { request } from "~/shared/libs/request";
|
||||||
|
|
||||||
|
const ratioTone = (v: number) => {
|
||||||
|
if (v >= 0.9) return "text-emerald-400";
|
||||||
|
if (v >= 0.6) return "text-yellow-400";
|
||||||
|
return "text-red-400";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AdminNetworkPage: React.FC = () => {
|
||||||
|
const [filterText, setFilterText] = useState("");
|
||||||
|
const [reachabilityFilter, setReachabilityFilter] = useState<"all" | "healthy" | "islands">("all");
|
||||||
|
const [sortBy, setSortBy] = useState<"leases" | "leaderships" | "reachability">("leases");
|
||||||
|
const [roleFilter, setRoleFilter] = useState<"all" | "trusted" | "read-only" | "deny">("all");
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(25);
|
||||||
|
|
||||||
|
const { data, isLoading, error, refetch } = useQuery(
|
||||||
|
["admin", "network", page, pageSize],
|
||||||
|
async () => {
|
||||||
|
const { data } = await request.get("/admin.network", { params: { page, page_size: pageSize } });
|
||||||
|
return data as any;
|
||||||
|
},
|
||||||
|
{ keepPreviousData: true },
|
||||||
|
);
|
||||||
|
|
||||||
|
const members = data?.members ?? [];
|
||||||
|
const replication = data?.per_node_replication ?? {};
|
||||||
|
const summary = data?.summary;
|
||||||
|
const receipts = data?.receipts ?? [];
|
||||||
|
|
||||||
|
const [selectedNode, setSelectedNode] = useState<any | null>(null);
|
||||||
|
|
||||||
|
const total = data?.paging?.total ?? (data?.members?.length ?? 0);
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ key: "node_id", label: "NodeID" },
|
||||||
|
{ key: "public_host", label: "Публичный адрес" },
|
||||||
|
{ key: "version", label: "Версия" },
|
||||||
|
{ key: "role", label: "Роль" },
|
||||||
|
{ key: "ip", label: "IP" },
|
||||||
|
{ key: "asn", label: "ASN" },
|
||||||
|
{ key: "reachability_ratio", label: "Достижимость" },
|
||||||
|
{ key: "leases", label: "Реплики" },
|
||||||
|
{ key: "leaderships", label: "Лидерства" },
|
||||||
|
{ key: "receipts", label: "Квитанции" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const rows = useMemo(() => {
|
||||||
|
let list = members.map((m: any) => {
|
||||||
|
const p = replication[m.node_id] ?? { leases_held: 0, leaderships: 0, sample_contents: [] };
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
leases: p.leases_held,
|
||||||
|
leaderships: p.leaderships,
|
||||||
|
_samples: p.sample_contents,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
// filter by text
|
||||||
|
if (filterText.trim()) {
|
||||||
|
const t = filterText.trim().toLowerCase();
|
||||||
|
list = list.filter((r: any) =>
|
||||||
|
(r.public_host ?? "").toLowerCase().includes(t) ||
|
||||||
|
(r.ip ?? "").toLowerCase().includes(t) ||
|
||||||
|
(r.node_id ?? "").toLowerCase().includes(t)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// role filter
|
||||||
|
if (roleFilter !== "all") {
|
||||||
|
list = list.filter((r: any) => (r.role ?? "read-only") === roleFilter);
|
||||||
|
}
|
||||||
|
// reachability filter
|
||||||
|
if (reachabilityFilter === "healthy") {
|
||||||
|
list = list.filter((r: any) => r.reachability_ratio >= 0.6);
|
||||||
|
} else if (reachabilityFilter === "islands") {
|
||||||
|
list = list.filter((r: any) => r.reachability_ratio < 0.6);
|
||||||
|
}
|
||||||
|
// sorting
|
||||||
|
list.sort((a: any, b: any) => {
|
||||||
|
if (sortBy === "leases") return b.leases - a.leases;
|
||||||
|
if (sortBy === "leaderships") return b.leaderships - a.leaderships;
|
||||||
|
return b.reachability_ratio - a.reachability_ratio;
|
||||||
|
});
|
||||||
|
// reset page if filters reduce list
|
||||||
|
return list;
|
||||||
|
}, [members, replication, filterText, reachabilityFilter, sortBy, roleFilter]);
|
||||||
|
const paged = rows; // сервер уже порезал список
|
||||||
|
|
||||||
|
if (isLoading) return <div className="p-4">Загрузка сети…</div>;
|
||||||
|
if (error) return <div className="p-4 text-red-400">Ошибка загрузки: {String(error)}</div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-6 p-4">
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-6 gap-4">
|
||||||
|
<div className="rounded-lg bg-slate-800/50 p-3">
|
||||||
|
<div className="text-xs text-slate-400">Оценка размера сети</div>
|
||||||
|
<div className="text-2xl font-semibold">{summary?.n_estimate ?? 0}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-slate-800/50 p-3">
|
||||||
|
<div className="text-xs text-slate-400">Оценка trusted‑сети</div>
|
||||||
|
<div className="text-2xl font-semibold">{summary?.n_estimate_trusted ?? 0}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-slate-800/50 p-3">
|
||||||
|
<div className="text-xs text-slate-400">Активные (все)</div>
|
||||||
|
<div className="text-2xl font-semibold">{summary?.active ?? 0}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-slate-800/50 p-3">
|
||||||
|
<div className="text-xs text-slate-400">Активные (trusted)</div>
|
||||||
|
<div className="text-2xl font-semibold">{summary?.active_trusted ?? 0}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-slate-800/50 p-3">
|
||||||
|
<div className="text-xs text-slate-400">Острова</div>
|
||||||
|
<div className="text-2xl font-semibold text-yellow-400">{summary?.islands ?? 0}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-slate-800/50 p-3">
|
||||||
|
<div className="text-xs text-slate-400">Конфликты репликаций</div>
|
||||||
|
<div className="text-sm">Недобор: <span className="text-red-400">{summary?.replication_conflicts.under ?? 0}</span></div>
|
||||||
|
<div className="text-sm">Перебор: <span className="text-yellow-400">{summary?.replication_conflicts.over ?? 0}</span></div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg bg-slate-800/50 p-3">
|
||||||
|
<div className="text-xs text-slate-400">Gossip/Backoff</div>
|
||||||
|
<div className="text-sm">Интервал: {summary?.config?.gossip_interval_sec ?? '-'} c</div>
|
||||||
|
<div className="text-sm">База: {summary?.config?.gossip_backoff_base_sec ?? '-'} c</div>
|
||||||
|
<div className="text-sm">Потолок: {summary?.config?.gossip_backoff_cap_sec ?? '-' } c</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg bg-slate-800/50 overflow-hidden">
|
||||||
|
<div className="px-3 py-2 text-sm text-slate-300 border-b border-slate-700 flex justify-between items-center">
|
||||||
|
<div className="flex items-center gap-3 flex-wrap">
|
||||||
|
<div>Узлы сети</div>
|
||||||
|
<input
|
||||||
|
className="bg-slate-900/50 text-sm px-2 py-1 rounded border border-slate-700 outline-none"
|
||||||
|
placeholder="Фильтр: host/IP/NodeID"
|
||||||
|
value={filterText}
|
||||||
|
onChange={(e) => setFilterText(e.target.value)}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="bg-slate-900/50 text-sm px-2 py-1 rounded border border-slate-700"
|
||||||
|
value={roleFilter}
|
||||||
|
onChange={(e) => setRoleFilter(e.target.value as any)}
|
||||||
|
>
|
||||||
|
<option value="all">Любая роль</option>
|
||||||
|
<option value="trusted">Только trusted</option>
|
||||||
|
<option value="read-only">Только read-only</option>
|
||||||
|
<option value="deny">Только deny</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="bg-slate-900/50 text-sm px-2 py-1 rounded border border-slate-700"
|
||||||
|
value={reachabilityFilter}
|
||||||
|
onChange={(e) => setReachabilityFilter(e.target.value as any)}
|
||||||
|
>
|
||||||
|
<option value="all">Все</option>
|
||||||
|
<option value="healthy">Только здоровые</option>
|
||||||
|
<option value="islands">Только острова</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="bg-slate-900/50 text-sm px-2 py-1 rounded border border-slate-700"
|
||||||
|
value={sortBy}
|
||||||
|
onChange={(e) => setSortBy(e.target.value as any)}
|
||||||
|
>
|
||||||
|
<option value="leases">По репликам</option>
|
||||||
|
<option value="leaderships">По лидерствам</option>
|
||||||
|
<option value="reachability">По достижимости</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="bg-slate-900/50 text-sm px-2 py-1 rounded border border-slate-700"
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => { setPage(1); setPageSize(parseInt(e.target.value)); }}
|
||||||
|
>
|
||||||
|
<option value={25}>25</option>
|
||||||
|
<option value={50}>50</option>
|
||||||
|
<option value={100}>100</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => refetch()} className="text-xs px-2 py-1 bg-slate-700 hover:bg-slate-600 rounded">
|
||||||
|
Обновить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-900/40">
|
||||||
|
{columns.map((c) => (
|
||||||
|
<th key={c.key} className="px-3 py-2 text-left text-slate-400 font-medium whitespace-nowrap">{c.label}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{paged.map((r: any) => (
|
||||||
|
<tr key={r.node_id} className="hover:bg-slate-900/30 cursor-pointer" onClick={() => setSelectedNode(r)}>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">{r.node_id.slice(0, 10)}…</td>
|
||||||
|
<td className="px-3 py-2 whitespace-nowrap">{r.public_host ?? '-'}</td>
|
||||||
|
<td className="px-3 py-2 whitespace-nowrap">{r.version ?? '-'}</td>
|
||||||
|
<td className="px-3 py-2 whitespace-nowrap">{r.role}</td>
|
||||||
|
<td className="px-3 py-2 whitespace-nowrap">{r.ip ?? '-'}</td>
|
||||||
|
<td className="px-3 py-2 whitespace-nowrap">{r.asn ?? '-'}</td>
|
||||||
|
<td className={`px-3 py-2 whitespace-nowrap ${ratioTone(r.reachability_ratio)}`}>{(r.reachability_ratio * 100).toFixed(0)}%</td>
|
||||||
|
<td className="px-3 py-2 whitespace-nowrap">{r.leases}</td>
|
||||||
|
<td className="px-3 py-2 whitespace-nowrap">{r.leaderships}</td>
|
||||||
|
<td className="px-3 py-2 whitespace-nowrap">{r.receipts_asn_unique ?? 0}/{r.receipts_total ?? 0}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/* Pagination controls */}
|
||||||
|
<div className="px-3 py-2 border-t border-slate-700 flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<div>Стр. {page} из {totalPages} • Всего: {total}</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="px-2 py-1 bg-slate-700 rounded disabled:opacity-50" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={page <= 1}>Назад</button>
|
||||||
|
<button className="px-2 py-1 bg-slate-700 rounded disabled:opacity-50" onClick={() => setPage((p) => Math.min(totalPages, p + 1))} disabled={page >= totalPages}>Вперёд</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reachability receipts table */}
|
||||||
|
<div className="rounded-lg bg-slate-800/50 overflow-hidden">
|
||||||
|
<div className="px-3 py-2 text-sm text-slate-300 border-b border-slate-700 flex justify-between items-center">
|
||||||
|
<div>Квитанции достижимости</div>
|
||||||
|
<div className="text-xs text-slate-400">{receipts.length} шт.</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-slate-900/40">
|
||||||
|
<th className="px-3 py-2 text-left text-slate-400 font-medium whitespace-nowrap">Target</th>
|
||||||
|
<th className="px-3 py-2 text-left text-slate-400 font-medium whitespace-nowrap">Issuer</th>
|
||||||
|
<th className="px-3 py-2 text-left text-slate-400 font-medium whitespace-nowrap">ASN</th>
|
||||||
|
<th className="px-3 py-2 text-left text-slate-400 font-medium whitespace-nowrap">Статус</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{receipts.map((r: any, i: number) => (
|
||||||
|
<tr key={`${r.issuer_id}:${r.target_id}:${i}`} className="hover:bg-slate-900/30">
|
||||||
|
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">{r.target_id.slice(0, 10)}…</td>
|
||||||
|
<td className="px-3 py-2 font-mono text-xs whitespace-nowrap">{r.issuer_id.slice(0, 10)}…</td>
|
||||||
|
<td className="px-3 py-2 whitespace-nowrap">{r.asn ?? '-'}</td>
|
||||||
|
<td className="px-3 py-2 whitespace-nowrap">
|
||||||
|
{r.status === 'valid' && <span className="text-emerald-400">подпись ок</span>}
|
||||||
|
{r.status === 'bad_signature' && <span className="text-red-400">ошибка подписи</span>}
|
||||||
|
{r.status === 'unknown_issuer' && <span className="text-yellow-400">неизвестный эмитент</span>}
|
||||||
|
{r.status === 'mismatch_node_id' && <span className="text-orange-400">node_id≠pubkey</span>}
|
||||||
|
{r.status === 'unknown' && <span className="text-slate-400">—</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-slate-500">
|
||||||
|
Подсказка: недобор/перебор репликаций сигнализируют о проблемах с диверсификацией или подсчётом N_estimate.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedNode && (
|
||||||
|
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-slate-900 rounded-lg border border-slate-700 w-[min(92vw,720px)] max-h-[80vh] overflow-auto">
|
||||||
|
<div className="px-4 py-3 border-b border-slate-700 flex items-center justify-between">
|
||||||
|
<div className="text-sm text-slate-300">Детали узла</div>
|
||||||
|
<button className="text-xs px-2 py-1 bg-slate-700 hover:bg-slate-600 rounded" onClick={() => setSelectedNode(null)}>Закрыть</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 text-sm space-y-2">
|
||||||
|
<div><span className="text-slate-400">NodeID:</span> <span className="font-mono text-xs">{selectedNode.node_id}</span></div>
|
||||||
|
<div><span className="text-slate-400">Публичный адрес:</span> {selectedNode.public_host ?? '—'}</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div><span className="text-slate-400">Версия:</span> {selectedNode.version ?? '—'}</div>
|
||||||
|
<div><span className="text-slate-400">Роль:</span> {selectedNode.role}</div>
|
||||||
|
<div><span className="text-slate-400">IP:</span> {selectedNode.ip ?? '—'}</div>
|
||||||
|
<div><span className="text-slate-400">ASN:</span> {selectedNode.asn ?? '—'}</div>
|
||||||
|
<div><span className="text-slate-400">Достижимость:</span> {(selectedNode.reachability_ratio * 100).toFixed(0)}%</div>
|
||||||
|
<div><span className="text-slate-400">Квитанции:</span> {selectedNode.receipts_asn_unique ?? 0}/{selectedNode.receipts_total ?? 0}</div>
|
||||||
|
<div><span className="text-slate-400">Реплики:</span> {selectedNode.leases}</div>
|
||||||
|
<div><span className="text-slate-400">Лидерства:</span> {selectedNode.leaderships}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-slate-400 mb-1">Примеры контента:</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(selectedNode._samples ?? []).map((c: string) => (
|
||||||
|
<span key={c} className="px-2 py-1 bg-slate-800 rounded text-xs font-mono">{c.slice(0,10)}…</span>
|
||||||
|
))}
|
||||||
|
{(!selectedNode._samples || selectedNode._samples.length === 0) && (
|
||||||
|
<span className="text-slate-500">—</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-slate-400 mb-1">Конфликты:</div>
|
||||||
|
<div className="max-h-40 overflow-auto border border-slate-800 rounded">
|
||||||
|
<table className="min-w-full text-xs">
|
||||||
|
<thead className="bg-slate-900">
|
||||||
|
<tr>
|
||||||
|
<th className="px-2 py-1 text-left text-slate-400">Тип</th>
|
||||||
|
<th className="px-2 py-1 text-left text-slate-400">Время</th>
|
||||||
|
<th className="px-2 py-1 text-left text-slate-400">Content</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{(selectedNode.conflict_samples ?? []).map((item: any, idx: number) => (
|
||||||
|
<tr key={idx} className="odd:bg-slate-900/40">
|
||||||
|
<td className="px-2 py-1 whitespace-nowrap">{item.type}</td>
|
||||||
|
<td className="px-2 py-1 whitespace-nowrap">{item.ts ? new Date(item.ts * 1000).toLocaleString() : '—'}</td>
|
||||||
|
<td className="px-2 py-1 font-mono whitespace-nowrap">{(item.content_id || '').slice(0, 10)}…</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{(!selectedNode.conflict_samples || selectedNode.conflict_samples.length === 0) && (
|
||||||
|
<tr><td colSpan={3} className="px-2 py-2 text-slate-500 text-center">Нет данных</td></tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selectedNode.public_host && (
|
||||||
|
<div className="pt-2">
|
||||||
|
<a href={selectedNode.public_host} target="_blank" rel="noreferrer" className="text-emerald-400 hover:underline">Открыть узел</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminNetworkPage;
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { request } from "~/shared/libs/request";
|
||||||
|
|
||||||
|
type Config = {
|
||||||
|
heartbeat_interval: number;
|
||||||
|
lease_ttl: number;
|
||||||
|
gossip_interval_sec: number;
|
||||||
|
gossip_backoff_base_sec: number;
|
||||||
|
gossip_backoff_cap_sec: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const AdminNetworkSettingsPage: React.FC = () => {
|
||||||
|
const [cfg, setCfg] = useState<Config | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await request.get("/admin.network.config");
|
||||||
|
setCfg(data.config);
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(String(e?.message || e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
useEffect(() => { void load(); }, []);
|
||||||
|
|
||||||
|
const update = async () => {
|
||||||
|
if (!cfg) return;
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
await request.post("/admin.network.config.set", cfg);
|
||||||
|
await load();
|
||||||
|
} catch (e: any) {
|
||||||
|
setError(String(e?.message || e));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!cfg) return <div className="p-4">Загрузка настроек…</div>;
|
||||||
|
|
||||||
|
const Field: React.FC<{ label: string; value: number; onChange: (v:number)=>void; tip?: string }> = ({label, value, onChange, tip}) => (
|
||||||
|
<label className="flex flex-col gap-1">
|
||||||
|
<span className="text-xs text-slate-400">{label}</span>
|
||||||
|
<input type="number" className="bg-slate-900/50 px-2 py-1 rounded border border-slate-700" value={value} onChange={(e)=>onChange(parseInt(e.target.value || '0'))} />
|
||||||
|
{tip && <span className="text-[10px] text-slate-500">{tip}</span>}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 flex flex-col gap-4">
|
||||||
|
<div className="text-lg font-semibold">Сеть → Настройки</div>
|
||||||
|
{error && <div className="text-sm text-red-400">Ошибка: {error}</div>}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Field label="Интервал heartbeat (сек)" value={cfg.heartbeat_interval} onChange={(v)=>setCfg({...cfg, heartbeat_interval: v})} tip="Период обновления лизов" />
|
||||||
|
<Field label="TTL лиза (сек)" value={cfg.lease_ttl} onChange={(v)=>setCfg({...cfg, lease_ttl: v})} tip="Время жизни лиза до истечения" />
|
||||||
|
<Field label="Интервал gossip (сек)" value={cfg.gossip_interval_sec} onChange={(v)=>setCfg({...cfg, gossip_interval_sec: v})} tip="Период рассылки снимка DHT" />
|
||||||
|
<Field label="Бэк-офф база (сек)" value={cfg.gossip_backoff_base_sec} onChange={(v)=>setCfg({...cfg, gossip_backoff_base_sec: v})} tip="Начальный бэк-офф для неуспешных пиров" />
|
||||||
|
<Field label="Бэк-офф потолок (сек)" value={cfg.gossip_backoff_cap_sec} onChange={(v)=>setCfg({...cfg, gossip_backoff_cap_sec: v})} tip="Максимальный бэк-офф" />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="px-3 py-2 bg-emerald-700 hover:bg-emerald-600 rounded disabled:opacity-50" onClick={()=>void update()} disabled={saving}>Сохранить</button>
|
||||||
|
<button className="px-3 py-2 bg-slate-700 hover:bg-slate-600 rounded" onClick={()=>void load()}>Сбросить</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-slate-500">Примечание: некоторые параметры читаются задачами-демонами при каждом цикле и применяются без перезапуска.</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default AdminNetworkSettingsPage;
|
||||||
|
|
||||||
|
|
@ -45,6 +45,13 @@ export const AdminOverviewPage = () => {
|
||||||
copyValue: node.service_wallet ?? null,
|
copyValue: node.service_wallet ?? null,
|
||||||
successMessage: "Service wallet скопирован",
|
successMessage: "Service wallet скопирован",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Highload Wallet",
|
||||||
|
value: node.highload_wallet ?? "—",
|
||||||
|
helper: "Высоконагрузочный",
|
||||||
|
copyValue: node.highload_wallet ?? null,
|
||||||
|
successMessage: "Highload wallet скопирован",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Контент",
|
label: "Контент",
|
||||||
value: `${numberFormatter.format(content.encrypted_total)} зашифр.`,
|
value: `${numberFormatter.format(content.encrypted_total)} зашифр.`,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
|
||||||
import { useAdminStars } from "~/shared/services/admin";
|
import { useAdminStars } from "~/shared/services/admin";
|
||||||
import { Section, PaginationControls, Badge } from "../components";
|
import { Section, PaginationControls, Badge } from "../components";
|
||||||
|
|
@ -7,7 +8,12 @@ import { formatDate, formatStars, numberFormatter } from "../utils/format";
|
||||||
|
|
||||||
export const AdminStarsPage = () => {
|
export const AdminStarsPage = () => {
|
||||||
const { isAuthorized, handleRequestError } = useAdminContext();
|
const { isAuthorized, handleRequestError } = useAdminContext();
|
||||||
const [search, setSearch] = useState("");
|
const location = useLocation();
|
||||||
|
const initialSearch = useMemo(() => {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
return params.get("search") ?? "";
|
||||||
|
}, [location.search]);
|
||||||
|
const [search, setSearch] = useState(initialSearch);
|
||||||
const [paidFilter, setPaidFilter] = useState<"all" | "paid" | "unpaid">("all");
|
const [paidFilter, setPaidFilter] = useState<"all" | "paid" | "unpaid">("all");
|
||||||
const [typeFilter, setTypeFilter] = useState("all");
|
const [typeFilter, setTypeFilter] = useState("all");
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
|
|
@ -31,6 +37,10 @@ export const AdminStarsPage = () => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSearch(initialSearch);
|
||||||
|
}, [initialSearch]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(0);
|
setPage(0);
|
||||||
}, [normalizedSearch, paidFilter, typeFilter]);
|
}, [normalizedSearch, paidFilter, typeFilter]);
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useAdminSystem } from "~/shared/services/admin";
|
import { useAdminSystem } from "~/shared/services/admin";
|
||||||
import { Section, InfoRow, Badge } from "../components";
|
import { Section, InfoRow, Badge, CopyButton } from "../components";
|
||||||
import { useAdminContext } from "../context";
|
import { useAdminContext } from "../context";
|
||||||
import { formatDate, formatUnknown, numberFormatter } from "../utils/format";
|
import { formatDate, formatUnknown, numberFormatter } from "../utils/format";
|
||||||
|
|
||||||
|
|
@ -20,7 +20,8 @@ export const AdminSystemPage = () => {
|
||||||
) : null;
|
) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { env, service_config, services, blockchain_tasks, latest_index_items } = systemQuery.data;
|
const { env, service_config, services, blockchain_tasks, latest_index_items, telegram_bots } = systemQuery.data;
|
||||||
|
const bots = telegram_bots ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Section id="system" title="Система" description="Ключевые переменные и внутренние конфиги">
|
<Section id="system" title="Система" description="Ключевые переменные и внутренние конфиги">
|
||||||
|
|
@ -35,6 +36,43 @@ export const AdminSystemPage = () => {
|
||||||
))}
|
))}
|
||||||
</dl>
|
</dl>
|
||||||
</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">Telegram-боты</h3>
|
||||||
|
{bots.length === 0 ? (
|
||||||
|
<p className="mt-3 text-xs text-slate-500">Боты не настроены.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="mt-3 space-y-3 text-sm text-slate-200">
|
||||||
|
{bots.map((bot) => (
|
||||||
|
<li
|
||||||
|
key={`${bot.role}-${bot.username}`}
|
||||||
|
className="flex flex-col gap-2 rounded-lg border border-slate-800 bg-slate-900/50 px-3 py-3"
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<Badge tone="neutral">{bot.role}</Badge>
|
||||||
|
<span className="font-semibold">@{bot.username}</span>
|
||||||
|
<CopyButton
|
||||||
|
value={bot.username}
|
||||||
|
aria-label="Скопировать username бота"
|
||||||
|
successMessage="Username скопирован"
|
||||||
|
className="h-6 w-6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-3 text-xs text-slate-400">
|
||||||
|
<span>t.me/{bot.username}</span>
|
||||||
|
<a
|
||||||
|
href={bot.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="rounded-lg border border-sky-500/40 px-3 py-1 text-[11px] font-semibold text-sky-200 transition hover:border-sky-400 hover:text-white"
|
||||||
|
>
|
||||||
|
Открыть бота
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
|
<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>
|
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-400">Задачи блокчейна</h3>
|
||||||
<dl className="mt-3 grid grid-cols-2 gap-3">
|
<dl className="mt-3 grid grid-cols-2 gap-3">
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
useAdminUploads,
|
useAdminUploads,
|
||||||
|
|
@ -118,6 +119,9 @@ const classifyUpload = (item: AdminUploadsContent): UploadDecoration => {
|
||||||
const user = item.stored.user;
|
const user = item.stored.user;
|
||||||
searchParts.push(user.id, user.telegram_id, user.username, user.first_name, user.last_name);
|
searchParts.push(user.id, user.telegram_id, user.username, user.first_name, user.last_name);
|
||||||
}
|
}
|
||||||
|
item.distribution?.nodes?.forEach((node) => {
|
||||||
|
searchParts.push(node.host, node.public_host, node.content.encrypted_cid);
|
||||||
|
});
|
||||||
const searchText = searchParts
|
const searchText = searchParts
|
||||||
.filter((value) => value !== null && value !== undefined && `${value}`.length > 0)
|
.filter((value) => value !== null && value !== undefined && `${value}`.length > 0)
|
||||||
.map((value) => `${value}`.toLowerCase())
|
.map((value) => `${value}`.toLowerCase())
|
||||||
|
|
@ -166,6 +170,7 @@ const UploadCard = ({ decoration }: { decoration: UploadDecoration }) => {
|
||||||
const { item, flags } = decoration;
|
const { item, flags } = decoration;
|
||||||
const problemMessages = decoration.hasIssue ? getProblemMessages(item) : [];
|
const problemMessages = decoration.hasIssue ? getProblemMessages(item) : [];
|
||||||
const derivativeDownloads = item.links.download_derivatives ?? [];
|
const derivativeDownloads = item.links.download_derivatives ?? [];
|
||||||
|
const distributionNodes = item.distribution?.nodes ?? [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-2xl border border-slate-800 bg-slate-950/40 p-5 shadow-inner shadow-black/40">
|
<div className="overflow-hidden rounded-2xl border border-slate-800 bg-slate-950/40 p-5 shadow-inner shadow-black/40">
|
||||||
|
|
@ -235,6 +240,79 @@ const UploadCard = ({ decoration }: { decoration: UploadDecoration }) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{distributionNodes.length > 0 ? (
|
||||||
|
<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-3 space-y-3 text-xs text-slate-300">
|
||||||
|
{distributionNodes.map((node) => {
|
||||||
|
const nodeTitle = node.is_local ? "Эта нода" : node.public_host ?? node.host ?? "Неизвестная нода";
|
||||||
|
const sizeLabel = node.content.size_bytes ? formatBytes(node.content.size_bytes) : null;
|
||||||
|
const updatedLabel = node.content.updated_at ? formatDate(node.content.updated_at) : null;
|
||||||
|
const lastSeenLabel = node.last_seen ? formatDate(node.last_seen) : null;
|
||||||
|
return (
|
||||||
|
<li key={`${item.encrypted_cid}-${node.node_id ?? "local"}`} className="rounded-lg border border-slate-800 bg-slate-950/50 p-3">
|
||||||
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-[11px] uppercase tracking-wide text-slate-400">
|
||||||
|
<span className="font-semibold text-slate-200">{nodeTitle}</span>
|
||||||
|
<Badge tone={node.is_local ? "success" : "neutral"}>{node.is_local ? "локально" : "удаленно"}</Badge>
|
||||||
|
{node.role ? <Badge tone="neutral">{node.role}</Badge> : null}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-[11px] text-slate-400">
|
||||||
|
{sizeLabel ? <p>Размер: {sizeLabel}</p> : null}
|
||||||
|
{updatedLabel ? <p>Обновлено: {updatedLabel}</p> : null}
|
||||||
|
{lastSeenLabel && !node.is_local ? <p>Нода активна: {lastSeenLabel}</p> : null}
|
||||||
|
{node.version ? <p>Версия: {node.version}</p> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{node.links.web_view ? (
|
||||||
|
<a
|
||||||
|
href={node.links.web_view}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="rounded-lg border border-emerald-500/40 px-3 py-1 text-[11px] font-semibold text-emerald-200 transition hover:border-emerald-400 hover:text-emerald-100"
|
||||||
|
>
|
||||||
|
Web
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
{node.links.api_view ? (
|
||||||
|
<>
|
||||||
|
<a
|
||||||
|
href={node.links.api_view}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="rounded-lg border border-indigo-500/40 px-3 py-1 text-[11px] font-semibold text-indigo-200 transition hover:border-indigo-400 hover:text-indigo-100"
|
||||||
|
>
|
||||||
|
API
|
||||||
|
</a>
|
||||||
|
<CopyButton
|
||||||
|
className="border border-indigo-500/40 text-indigo-200 hover:border-indigo-400 hover:text-indigo-100"
|
||||||
|
value={node.links.api_view}
|
||||||
|
>
|
||||||
|
копировать API
|
||||||
|
</CopyButton>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
{node.links.gateway_view ? (
|
||||||
|
<a
|
||||||
|
href={node.links.gateway_view}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="rounded-lg border border-amber-500/40 px-3 py-1 text-[11px] font-semibold text-amber-200 transition hover:border-amber-400 hover:text-amber-100"
|
||||||
|
>
|
||||||
|
Gateway
|
||||||
|
</a>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="rounded-xl bg-slate-900/60 p-4 ring-1 ring-slate-800">
|
<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>
|
<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">
|
<ul className="mt-2 space-y-2 text-xs text-slate-300">
|
||||||
|
|
@ -397,8 +475,17 @@ const UploadCard = ({ decoration }: { decoration: UploadDecoration }) => {
|
||||||
|
|
||||||
export const AdminUploadsPage = () => {
|
export const AdminUploadsPage = () => {
|
||||||
const { isAuthorized, handleRequestError } = useAdminContext();
|
const { isAuthorized, handleRequestError } = useAdminContext();
|
||||||
|
const location = useLocation();
|
||||||
const [uploadsFilter, setUploadsFilter] = useState<UploadFilter>("all");
|
const [uploadsFilter, setUploadsFilter] = useState<UploadFilter>("all");
|
||||||
const [uploadsSearch, setUploadsSearch] = useState("");
|
const initialUploadsSearch = useMemo(() => {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
return params.get("search") ?? "";
|
||||||
|
}, [location.search]);
|
||||||
|
const [uploadsSearch, setUploadsSearch] = useState(initialUploadsSearch);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUploadsSearch(initialUploadsSearch);
|
||||||
|
}, [initialUploadsSearch]);
|
||||||
|
|
||||||
const normalizedUploadsSearch = uploadsSearch.trim();
|
const normalizedUploadsSearch = uploadsSearch.trim();
|
||||||
const hasUploadsSearch = normalizedUploadsSearch.length > 0;
|
const hasUploadsSearch = normalizedUploadsSearch.length > 0;
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,21 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useLocation } from "react-router-dom";
|
||||||
|
|
||||||
import { useAdminUsers } from "~/shared/services/admin";
|
import { useAdminUsers, useAdminSetUserAdmin } from "~/shared/services/admin";
|
||||||
import { Section, PaginationControls, CopyButton } from "../components";
|
import { Section, PaginationControls, CopyButton, Badge } from "../components";
|
||||||
import { useAdminContext } from "../context";
|
import { useAdminContext } from "../context";
|
||||||
import { formatDate, formatStars, numberFormatter } from "../utils/format";
|
import { formatDate, formatStars, numberFormatter } from "../utils/format";
|
||||||
|
|
||||||
export const AdminUsersPage = () => {
|
export const AdminUsersPage = () => {
|
||||||
const { isAuthorized, handleRequestError } = useAdminContext();
|
const { isAuthorized, handleRequestError, pushFlash } = useAdminContext();
|
||||||
const [search, setSearch] = useState("");
|
const location = useLocation();
|
||||||
|
const initialSearchValue = useMemo(() => {
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
return params.get("search") ?? "";
|
||||||
|
}, [location.search]);
|
||||||
|
const [search, setSearch] = useState(initialSearchValue);
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
|
const [pendingUserId, setPendingUserId] = useState<number | null>(null);
|
||||||
|
|
||||||
const limit = 50;
|
const limit = 50;
|
||||||
const normalizedSearch = search.trim();
|
const normalizedSearch = search.trim();
|
||||||
|
|
@ -27,6 +34,33 @@ export const AdminUsersPage = () => {
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const setUserAdmin = useAdminSetUserAdmin({
|
||||||
|
onMutate: ({ user_id }) => {
|
||||||
|
setPendingUserId(user_id);
|
||||||
|
},
|
||||||
|
onSuccess: ({ user }) => {
|
||||||
|
pushFlash({
|
||||||
|
type: "success",
|
||||||
|
message: user.is_admin ? "Пользователь назначен администратором" : "Права администратора сняты",
|
||||||
|
});
|
||||||
|
void usersQuery.refetch();
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
handleRequestError(error, "Не удалось обновить права администратора");
|
||||||
|
},
|
||||||
|
onSettled: () => {
|
||||||
|
setPendingUserId(null);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleToggleAdmin = (userId: number, nextValue: boolean) => {
|
||||||
|
setUserAdmin.mutate({ user_id: userId, is_admin: nextValue });
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSearch(initialSearchValue);
|
||||||
|
}, [initialSearchValue]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(0);
|
setPage(0);
|
||||||
}, [normalizedSearch]);
|
}, [normalizedSearch]);
|
||||||
|
|
@ -54,6 +88,11 @@ export const AdminUsersPage = () => {
|
||||||
value: numberFormatter.format(total),
|
value: numberFormatter.format(total),
|
||||||
helper: `На странице: ${numberFormatter.format(summary.users_returned)}`,
|
helper: `На странице: ${numberFormatter.format(summary.users_returned)}`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Администраторы",
|
||||||
|
value: numberFormatter.format(summary.admins_total ?? 0),
|
||||||
|
helper: summary.admins_total ? "Имеют доступ к /admin" : "Назначьте администраторов",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: "Кошельки (активные/всего)",
|
label: "Кошельки (активные/всего)",
|
||||||
value: `${numberFormatter.format(summary.wallets_active)} / ${numberFormatter.format(summary.wallets_total)}`,
|
value: `${numberFormatter.format(summary.wallets_active)} / ${numberFormatter.format(summary.wallets_total)}`,
|
||||||
|
|
@ -165,6 +204,23 @@ export const AdminUsersPage = () => {
|
||||||
{(user.first_name ?? "")} {(user.last_name ?? "")}
|
{(user.first_name ?? "")} {(user.last_name ?? "")}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-2 text-xs">
|
||||||
|
<Badge tone={user.is_admin ? "success" : "neutral"}>
|
||||||
|
{user.is_admin ? "Администратор" : "Пользователь"}
|
||||||
|
</Badge>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleToggleAdmin(user.id, !user.is_admin)}
|
||||||
|
className="rounded-lg border border-slate-700 px-3 py-1 text-[11px] font-semibold text-slate-200 transition hover:border-slate-500 hover:text-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
disabled={setUserAdmin.isLoading}
|
||||||
|
>
|
||||||
|
{pendingUserId === user.id && setUserAdmin.isLoading
|
||||||
|
? "Сохраняем…"
|
||||||
|
: user.is_admin
|
||||||
|
? "Снять права"
|
||||||
|
: "Назначить админом"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3 align-top">
|
<td className="px-3 py-3 align-top">
|
||||||
<div className="text-xs text-slate-400">
|
<div className="text-xs text-slate-400">
|
||||||
|
|
@ -291,6 +347,26 @@ export const AdminUsersPage = () => {
|
||||||
{numberFormatter.format(user.licenses.active)}
|
{numberFormatter.format(user.licenses.active)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<span className="text-slate-500">Роль</span>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<Badge tone={user.is_admin ? "success" : "neutral"}>
|
||||||
|
{user.is_admin ? "Администратор" : "Пользователь"}
|
||||||
|
</Badge>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleToggleAdmin(user.id, !user.is_admin)}
|
||||||
|
className="rounded-lg border border-slate-700 px-3 py-1 text-[11px] font-semibold text-slate-200 transition hover:border-slate-500 hover:text-white disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
|
disabled={setUserAdmin.isLoading}
|
||||||
|
>
|
||||||
|
{pendingUserId === user.id && setUserAdmin.isLoading
|
||||||
|
? "Сохраняем…"
|
||||||
|
: user.is_admin
|
||||||
|
? "Снять права"
|
||||||
|
: "Назначить админом"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="flex flex-col gap-1">
|
<div className="flex flex-col gap-1">
|
||||||
<span className="text-slate-500">Последняя активность</span>
|
<span className="text-slate-500">Последняя активность</span>
|
||||||
{user.ip_activity.last ? (
|
{user.ip_activity.last ? (
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
export { AdminOverviewPage } from "./Overview";
|
export { AdminOverviewPage } from "./Overview";
|
||||||
export { AdminStoragePage } from "./Storage";
|
export { AdminStoragePage } from "./Storage";
|
||||||
export { AdminUploadsPage } from "./Uploads";
|
export { AdminUploadsPage } from "./Uploads";
|
||||||
|
export { AdminEventsPage } from "./Events";
|
||||||
export { AdminUsersPage } from "./Users";
|
export { AdminUsersPage } from "./Users";
|
||||||
export { AdminLicensesPage } from "./Licenses";
|
export { AdminLicensesPage } from "./Licenses";
|
||||||
export { AdminStarsPage } from "./Stars";
|
export { AdminStarsPage } from "./Stars";
|
||||||
|
|
@ -8,3 +9,5 @@ export { AdminSystemPage } from "./System";
|
||||||
export { AdminBlockchainPage } from "./Blockchain";
|
export { AdminBlockchainPage } from "./Blockchain";
|
||||||
export { AdminNodesPage } from "./Nodes";
|
export { AdminNodesPage } from "./Nodes";
|
||||||
export { AdminStatusPage } from "./Status";
|
export { AdminStatusPage } from "./Status";
|
||||||
|
export { AdminNetworkPage } from "./Network";
|
||||||
|
export { AdminNetworkSettingsPage } from "./NetworkSettings";
|
||||||
|
|
|
||||||
|
|
@ -52,9 +52,22 @@ export const ViewContentPage = () => {
|
||||||
}
|
}
|
||||||
}, [statusState]);
|
}, [statusState]);
|
||||||
|
|
||||||
|
const haveLicense = useMemo(() => (
|
||||||
|
content?.data?.have_licenses?.includes('listen') ||
|
||||||
|
content?.data?.have_licenses?.includes('resale')
|
||||||
|
), [content]);
|
||||||
|
|
||||||
|
const contentMime = content?.data?.content_mime ?? null;
|
||||||
|
const contentKind = content?.data?.display_options?.content_kind ?? null;
|
||||||
const mediaUrl = content?.data?.display_options?.content_url ?? null;
|
const mediaUrl = content?.data?.display_options?.content_url ?? null;
|
||||||
const isAudio = Boolean(content?.data?.content_type?.startsWith('audio'));
|
const isAudio = contentKind === 'audio' || Boolean(content?.data?.content_type?.startsWith('audio'));
|
||||||
const isReady = Boolean(mediaUrl);
|
const isVideo = contentKind === 'video' || Boolean(content?.data?.content_type?.startsWith('video'));
|
||||||
|
const isBinary = contentKind === 'binary' || (!isAudio && !isVideo);
|
||||||
|
const hasInlinePlayer = Boolean(mediaUrl) && (isAudio || isVideo);
|
||||||
|
const binaryDownloadReady = Boolean(mediaUrl) && isBinary;
|
||||||
|
const isReadyState = statusState === "ready";
|
||||||
|
const previewAvailable = Boolean(content?.data?.display_options?.has_preview);
|
||||||
|
const coverImage = content?.data?.display_options?.metadata?.image ?? null;
|
||||||
const metadataName = content?.data?.display_options?.metadata?.name;
|
const metadataName = content?.data?.display_options?.metadata?.name;
|
||||||
const contentTitle = metadataName || content?.data?.encrypted?.title || 'Контент';
|
const contentTitle = metadataName || content?.data?.encrypted?.title || 'Контент';
|
||||||
const processingDetails = useMemo(() => {
|
const processingDetails = useMemo(() => {
|
||||||
|
|
@ -67,6 +80,10 @@ export const ViewContentPage = () => {
|
||||||
};
|
};
|
||||||
}, [conversionState, statusMessage, uploadState]);
|
}, [conversionState, statusMessage, uploadState]);
|
||||||
|
|
||||||
|
const canDownload = Boolean(mediaUrl) && haveLicense && ((content?.data?.downloadable ?? false) || isBinary);
|
||||||
|
const binaryAwaitingAccess = isBinary && !binaryDownloadReady;
|
||||||
|
const isFailed = statusState === "failed";
|
||||||
|
|
||||||
const handleBuyContentTON = useCallback(async () => {
|
const handleBuyContentTON = useCallback(async () => {
|
||||||
if (!contentId) {
|
if (!contentId) {
|
||||||
console.error('No content identifier available for purchase');
|
console.error('No content identifier available for purchase');
|
||||||
|
|
@ -185,11 +202,6 @@ export const ViewContentPage = () => {
|
||||||
}
|
}
|
||||||
}, [content, refetchContent]);
|
}, [content, refetchContent]);
|
||||||
|
|
||||||
const haveLicense = useMemo(() => (
|
|
||||||
content?.data?.have_licenses?.includes('listen') ||
|
|
||||||
content?.data?.have_licenses?.includes('resale')
|
|
||||||
), [content]);
|
|
||||||
|
|
||||||
const hadLicenseRef = useRef<boolean>(haveLicense);
|
const hadLicenseRef = useRef<boolean>(haveLicense);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -230,11 +242,15 @@ export const ViewContentPage = () => {
|
||||||
const handleDwnldContent = async () => {
|
const handleDwnldContent = async () => {
|
||||||
try {
|
try {
|
||||||
const fileUrl = content?.data?.display_options?.content_url;
|
const fileUrl = content?.data?.display_options?.content_url;
|
||||||
|
if (!fileUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const fileName = content?.data?.display_options?.metadata?.name || 'content';
|
const fileName = content?.data?.display_options?.metadata?.name || 'content';
|
||||||
const fileFormat = content?.data?.content_ext || '.raw';
|
const rawExt = content?.data?.content_ext ?? 'bin';
|
||||||
|
const normalizedExt = rawExt.replace(/^\.+/, '');
|
||||||
await WebApp.downloadFile({
|
await WebApp.downloadFile({
|
||||||
url: fileUrl,
|
url: fileUrl,
|
||||||
file_name: fileName + '.' + fileFormat,
|
file_name: `${fileName}.${normalizedExt}`,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error downloading content:', error);
|
console.error('Error downloading content:', error);
|
||||||
|
|
@ -242,23 +258,28 @@ export const ViewContentPage = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className={'min-h-screen flex w-full flex-col gap-[50px] px-4 '}>
|
<main className={'min-h-screen flex w-full flex-col gap-[40px] px-4 '}>
|
||||||
{isCongratsModal && <CongratsModal onConfirm={handleConfirmCongrats} />}
|
{isCongratsModal && <CongratsModal onConfirm={handleConfirmCongrats} />}
|
||||||
{isErrorModal && <ErrorModal onConfirm={handleErrorModal} />}
|
{isErrorModal && <ErrorModal onConfirm={handleErrorModal} />}
|
||||||
{isReady && isAudio &&
|
{coverImage && (
|
||||||
content?.data?.display_options?.metadata?.image && (
|
<div className="mt-[30px] flex w-full justify-center">
|
||||||
<div className={'mt-[30px] h-[314px] w-full'}>
|
<div className="relative aspect-square w-full max-w-[320px] rounded-3xl border border-slate-900/60 bg-transparent">
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center overflow-hidden">
|
||||||
<img
|
<img
|
||||||
alt={'content_image'}
|
alt={'content cover'}
|
||||||
className={'h-full w-full object-cover object-center'}
|
className={'max-h-full max-w-full object-contain'}
|
||||||
src={content?.data?.display_options?.metadata?.image}
|
src={coverImage}
|
||||||
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isReady ? (
|
{isReadyState ? (
|
||||||
<>
|
<>
|
||||||
{isAudio ? (
|
{hasInlinePlayer && (
|
||||||
|
isAudio ? (
|
||||||
<AudioPlayer src={mediaUrl ?? ''} />
|
<AudioPlayer src={mediaUrl ?? ''} />
|
||||||
) : (
|
) : (
|
||||||
<ReactPlayer
|
<ReactPlayer
|
||||||
|
|
@ -274,8 +295,19 @@ export const ViewContentPage = () => {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
url={mediaUrl}
|
url={mediaUrl ?? ''}
|
||||||
|
light={coverImage || undefined}
|
||||||
/>
|
/>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
|
{binaryDownloadReady && (
|
||||||
|
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 px-6 py-5 shadow-inner shadow-black/20">
|
||||||
|
<h2 className="text-base font-semibold text-slate-100">Файл готов к скачиванию</h2>
|
||||||
|
<p className="mt-2 text-sm text-slate-300">
|
||||||
|
{contentMime ? `Тип файла: ${contentMime}` : 'Скачайте оригинальные данные на устройство.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<section className={'flex flex-col'}>
|
<section className={'flex flex-col'}>
|
||||||
|
|
@ -285,26 +317,40 @@ export const ViewContentPage = () => {
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{binaryAwaitingAccess && (
|
||||||
|
<div className="rounded-2xl border border-slate-800 bg-slate-950/70 px-6 py-5 text-sm text-slate-300">
|
||||||
|
<h2 className="text-base font-semibold text-slate-100">Предпросмотр недоступен</h2>
|
||||||
|
<p className="mt-2">
|
||||||
|
Этот файл нельзя открыть в браузере. Получите доступ, чтобы скачать оригинал на устройство.
|
||||||
|
</p>
|
||||||
|
{!previewAvailable && (
|
||||||
|
<p className="mt-2 text-xs text-slate-500">
|
||||||
|
Предпросмотр не формируется для бинарных данных.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="mt-auto pb-2">
|
<div className="mt-auto pb-2">
|
||||||
{content?.data?.downloadable && (
|
{canDownload && (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => handleDwnldContent()}
|
onClick={() => handleDwnldContent()}
|
||||||
className={'h-[48px] mb-4'}
|
className={'h-[48px] mb-4'}
|
||||||
label={`Скачать контент`}
|
label={`Скачать ${isBinary ? 'файл' : 'контент'}`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{!haveLicense && (
|
{!haveLicense && (
|
||||||
<div className="flex flex-row gap-4">
|
<div className="flex gap-4 pb-2 flex-nowrap overflow-hidden">
|
||||||
<Button
|
<Button
|
||||||
onClick={handleBuyContentTON}
|
onClick={handleBuyContentTON}
|
||||||
className={'mb-4 h-[48px] px-2'}
|
className={'mb-4 h-[48px] px-2 flex-1 min-w-0 w-auto truncate'}
|
||||||
label={`Купить за ${fromNanoTON(content?.data?.encrypted?.license?.resale?.price)} ТОН`}
|
label={`Купить за ${fromNanoTON(content?.data?.encrypted?.license?.resale?.price)} ТОН`}
|
||||||
includeArrows={content?.data?.invoice ? false : true}
|
includeArrows={content?.data?.invoice ? false : true}
|
||||||
/>
|
/>
|
||||||
{content?.data?.invoice && (
|
{content?.data?.invoice && (
|
||||||
<Button
|
<Button
|
||||||
onClick={handleBuyContentStars}
|
onClick={handleBuyContentStars}
|
||||||
className={'mb-4 h-[48px] px-2'}
|
className={'mb-4 h-[48px] px-2 flex-1 min-w-0 w-auto truncate'}
|
||||||
label={`Купить за ${content?.data?.invoice?.amount} ⭐️`}
|
label={`Купить за ${content?.data?.invoice?.amount} ⭐️`}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -332,10 +378,12 @@ export const ViewContentPage = () => {
|
||||||
<div className="flex flex-1 flex-col items-center justify-center py-16">
|
<div className="flex flex-1 flex-col items-center justify-center py-16">
|
||||||
<div className="max-w-md rounded-2xl border border-slate-800 bg-slate-950/70 px-6 py-8 text-center shadow-lg shadow-black/30">
|
<div className="max-w-md rounded-2xl border border-slate-800 bg-slate-950/70 px-6 py-8 text-center shadow-lg shadow-black/30">
|
||||||
<h1 className="text-lg font-semibold text-slate-100">
|
<h1 className="text-lg font-semibold text-slate-100">
|
||||||
Контент скоро будет здесь
|
{isFailed ? 'Не удалось подготовить контент' : 'Контент скоро будет здесь'}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-3 text-sm text-slate-300">
|
<p className="mt-3 text-sm text-slate-300">
|
||||||
Мы уже обрабатываем загруженный файл и обновим страницу автоматически, как только появится доступ к полному контенту.
|
{isFailed
|
||||||
|
? 'При обработке файла произошла ошибка. Попробуйте обновить страницу или повторно загрузить контент.'
|
||||||
|
: 'Мы уже обрабатываем загруженный файл и обновим страницу автоматически, как только появится доступ к полному контенту.'}
|
||||||
</p>
|
</p>
|
||||||
{statusMessage && (
|
{statusMessage && (
|
||||||
<p className="mt-4 text-[12px] text-slate-500">
|
<p className="mt-4 text-[12px] text-slate-500">
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ export type AdminOverviewResponse = {
|
||||||
id: string;
|
id: string;
|
||||||
service_wallet: string;
|
service_wallet: string;
|
||||||
ton_master: string;
|
ton_master: string;
|
||||||
|
highload_wallet: string | null;
|
||||||
};
|
};
|
||||||
runtime: {
|
runtime: {
|
||||||
python: string;
|
python: string;
|
||||||
|
|
@ -143,6 +144,35 @@ export type AdminUploadsContentFlags = {
|
||||||
unindexed: boolean;
|
unindexed: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AdminContentDistributionNode = {
|
||||||
|
node_id: number | null;
|
||||||
|
is_local: boolean;
|
||||||
|
host: string | null;
|
||||||
|
public_host: string | null;
|
||||||
|
version: string | null;
|
||||||
|
role: string | null;
|
||||||
|
last_seen: string | null;
|
||||||
|
content: {
|
||||||
|
encrypted_cid: string | null;
|
||||||
|
content_type: string | null;
|
||||||
|
size_bytes: number | null;
|
||||||
|
preview_enabled: boolean | null;
|
||||||
|
updated_at: string | null;
|
||||||
|
metadata_cid?: string | null;
|
||||||
|
issuer_node_id?: string | null;
|
||||||
|
};
|
||||||
|
links: {
|
||||||
|
web_view: string | null;
|
||||||
|
api_view: string | null;
|
||||||
|
gateway_view: string | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminContentDistribution = {
|
||||||
|
local_present: boolean;
|
||||||
|
nodes: AdminContentDistributionNode[];
|
||||||
|
};
|
||||||
|
|
||||||
export type AdminUploadsContentStatus = {
|
export type AdminUploadsContentStatus = {
|
||||||
upload_state: string | null;
|
upload_state: string | null;
|
||||||
conversion_state: string | null;
|
conversion_state: string | null;
|
||||||
|
|
@ -175,6 +205,7 @@ export type AdminUploadsContent = {
|
||||||
ipfs: AdminUploadsContentIpfs;
|
ipfs: AdminUploadsContentIpfs;
|
||||||
stored: AdminUploadsContentStored;
|
stored: AdminUploadsContentStored;
|
||||||
links: AdminUploadsContentLinks;
|
links: AdminUploadsContentLinks;
|
||||||
|
distribution: AdminContentDistribution;
|
||||||
flags?: AdminUploadsContentFlags;
|
flags?: AdminUploadsContentFlags;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -264,6 +295,7 @@ export type AdminUserIpActivity = {
|
||||||
|
|
||||||
export type AdminUserItem = {
|
export type AdminUserItem = {
|
||||||
id: number;
|
id: number;
|
||||||
|
is_admin: boolean;
|
||||||
telegram_id: number;
|
telegram_id: number;
|
||||||
username: string | null;
|
username: string | null;
|
||||||
first_name: string | null;
|
first_name: string | null;
|
||||||
|
|
@ -283,8 +315,48 @@ export type AdminUserItem = {
|
||||||
ip_activity: AdminUserIpActivity;
|
ip_activity: AdminUserIpActivity;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AdminEventItem = {
|
||||||
|
id: number;
|
||||||
|
origin_public_key: string | null;
|
||||||
|
origin_host: string | null;
|
||||||
|
seq: number;
|
||||||
|
uid: string;
|
||||||
|
event_type: string;
|
||||||
|
status: string;
|
||||||
|
created_at: string | null;
|
||||||
|
received_at: string | null;
|
||||||
|
applied_at: string | null;
|
||||||
|
payload: Record<string, unknown>;
|
||||||
|
links: Record<string, string | null>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminEventsResponse = {
|
||||||
|
total: number;
|
||||||
|
limit: number;
|
||||||
|
offset: number;
|
||||||
|
filters?: Record<string, unknown>;
|
||||||
|
items: AdminEventItem[];
|
||||||
|
available_filters: {
|
||||||
|
types: Record<string, number>;
|
||||||
|
statuses: Record<string, number>;
|
||||||
|
origins: Record<string, number>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminEventsQueryParams = {
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
type?: string | string[];
|
||||||
|
status?: string | string[];
|
||||||
|
origin?: string | string[];
|
||||||
|
search?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AdminEventsQueryKey = ['admin', 'events', string];
|
||||||
|
|
||||||
export type AdminUsersSummary = {
|
export type AdminUsersSummary = {
|
||||||
users_returned: number;
|
users_returned: number;
|
||||||
|
admins_total: number;
|
||||||
wallets_total: number;
|
wallets_total: number;
|
||||||
wallets_active: number;
|
wallets_active: number;
|
||||||
licenses_total: number;
|
licenses_total: number;
|
||||||
|
|
@ -472,6 +544,11 @@ export type AdminSystemResponse = {
|
||||||
encrypted_cid: string | null;
|
encrypted_cid: string | null;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
}>;
|
}>;
|
||||||
|
telegram_bots: Array<{
|
||||||
|
role: string;
|
||||||
|
username: string;
|
||||||
|
url: string;
|
||||||
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type AdminBlockchainResponse = {
|
export type AdminBlockchainResponse = {
|
||||||
|
|
@ -500,6 +577,78 @@ export type AdminNodesResponse = {
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --------- Состояние сети (новый раздел) ---------
|
||||||
|
|
||||||
|
export type AdminNetworkMember = {
|
||||||
|
node_id: string;
|
||||||
|
public_key: string | null;
|
||||||
|
public_host: string | null;
|
||||||
|
version: string | null;
|
||||||
|
role: string;
|
||||||
|
ip: string | null;
|
||||||
|
asn: number | null;
|
||||||
|
ip_first_octet: number | null;
|
||||||
|
reachability_ratio: number;
|
||||||
|
last_update: number | null;
|
||||||
|
accepts_inbound: boolean;
|
||||||
|
is_bootstrap: boolean;
|
||||||
|
receipts_total?: number;
|
||||||
|
receipts_asn_unique?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AdminNetworkPerNodeReplication = Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
leases_held: number;
|
||||||
|
leaderships: number;
|
||||||
|
sample_contents: string[];
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
|
||||||
|
export type AdminNetworkResponse = {
|
||||||
|
summary: {
|
||||||
|
n_estimate: number;
|
||||||
|
n_estimate_trusted: number;
|
||||||
|
active_trusted: number;
|
||||||
|
members_total: number;
|
||||||
|
active: number;
|
||||||
|
islands: number;
|
||||||
|
replication_conflicts: { under: number; over: number };
|
||||||
|
config: {
|
||||||
|
heartbeat_interval: number;
|
||||||
|
lease_ttl: number;
|
||||||
|
gossip_interval_sec: number;
|
||||||
|
gossip_backoff_base_sec: number;
|
||||||
|
gossip_backoff_cap_sec: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
members: AdminNetworkMember[];
|
||||||
|
per_node_replication: AdminNetworkPerNodeReplication;
|
||||||
|
receipts: Array<{
|
||||||
|
target_id: string;
|
||||||
|
issuer_id: string;
|
||||||
|
asn: number | null;
|
||||||
|
timestamp: number | null;
|
||||||
|
status: 'valid' | 'bad_signature' | 'unknown_issuer' | 'mismatch_node_id' | 'unknown';
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const useAdminNetwork = (
|
||||||
|
options?: QueryOptions<AdminNetworkResponse, ['admin', 'network']>,
|
||||||
|
) => {
|
||||||
|
return useQuery<AdminNetworkResponse, AxiosError, AdminNetworkResponse, ['admin', 'network']>(
|
||||||
|
['admin', 'network'],
|
||||||
|
async () => {
|
||||||
|
const { data } = await request.get<AdminNetworkResponse>('/admin.network');
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...defaultQueryOptions,
|
||||||
|
...options,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export type AdminStatusResponse = {
|
export type AdminStatusResponse = {
|
||||||
ipfs: {
|
ipfs: {
|
||||||
bitswap: Record<string, unknown>;
|
bitswap: Record<string, unknown>;
|
||||||
|
|
@ -773,6 +922,55 @@ export const useAdminStars = (
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useAdminEvents = (
|
||||||
|
params?: AdminEventsQueryParams,
|
||||||
|
options?: QueryOptions<AdminEventsResponse, AdminEventsQueryKey>,
|
||||||
|
) => {
|
||||||
|
const normalizedSearch = params?.search?.trim() || null;
|
||||||
|
const typeValue = Array.isArray(params?.type)
|
||||||
|
? params.type.filter(Boolean).join(',')
|
||||||
|
: params?.type ?? null;
|
||||||
|
const statusValue = Array.isArray(params?.status)
|
||||||
|
? params.status.filter(Boolean).join(',')
|
||||||
|
: params?.status ?? null;
|
||||||
|
const originValue = Array.isArray(params?.origin)
|
||||||
|
? params.origin.filter(Boolean).join(',')
|
||||||
|
: params?.origin ?? null;
|
||||||
|
|
||||||
|
const paramsKeyPayload = {
|
||||||
|
limit: params?.limit ?? null,
|
||||||
|
offset: params?.offset ?? null,
|
||||||
|
search: normalizedSearch,
|
||||||
|
type: typeValue,
|
||||||
|
status: statusValue,
|
||||||
|
origin: originValue,
|
||||||
|
};
|
||||||
|
const paramsKey = JSON.stringify(paramsKeyPayload);
|
||||||
|
|
||||||
|
const queryParams: Record<string, string | number | undefined> = {
|
||||||
|
limit: params?.limit,
|
||||||
|
offset: params?.offset,
|
||||||
|
search: normalizedSearch ?? undefined,
|
||||||
|
type: typeValue ?? undefined,
|
||||||
|
status: statusValue ?? undefined,
|
||||||
|
origin: originValue ?? undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
return useQuery<AdminEventsResponse, AxiosError, AdminEventsResponse, AdminEventsQueryKey>(
|
||||||
|
['admin', 'events', paramsKey],
|
||||||
|
async () => {
|
||||||
|
const { data } = await request.get<AdminEventsResponse>('/admin.events', {
|
||||||
|
params: queryParams,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
{
|
||||||
|
...defaultQueryOptions,
|
||||||
|
...options,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const useAdminSystem = (
|
export const useAdminSystem = (
|
||||||
options?: QueryOptions<AdminSystemResponse, ['admin', 'system']>,
|
options?: QueryOptions<AdminSystemResponse, ['admin', 'system']>,
|
||||||
) => {
|
) => {
|
||||||
|
|
@ -930,6 +1128,18 @@ export const useAdminNodeSetRole = (
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useAdminSetUserAdmin = (
|
||||||
|
options?: MutationOptions<{ ok: true; user: { id: number; is_admin: boolean } }, { user_id: number; is_admin: boolean }>,
|
||||||
|
) => {
|
||||||
|
return useMutation<{ ok: true; user: { id: number; is_admin: boolean } }, AxiosError, { user_id: number; is_admin: boolean }>(
|
||||||
|
async (payload) => {
|
||||||
|
const { data } = await request.post<{ ok: true; user: { id: number; is_admin: boolean } }>('/admin.users.setAdmin', payload);
|
||||||
|
return data;
|
||||||
|
},
|
||||||
|
options,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const useAdminSyncLimits = () => {
|
export const useAdminSyncLimits = () => {
|
||||||
const cacheSetLimits = useAdminCacheSetLimits();
|
const cacheSetLimits = useAdminCacheSetLimits();
|
||||||
const syncSetLimits = useAdminSyncSetLimits();
|
const syncSetLimits = useAdminSyncSetLimits();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { useRef, useEffect } from 'react';
|
import { useRef } from 'react';
|
||||||
import { useTonConnectUI } from '@tonconnect/ui-react';
|
import { useTonConnectUI } from '@tonconnect/ui-react';
|
||||||
import { useMutation } from 'react-query';
|
import { useMutation } from 'react-query';
|
||||||
import { request } from '~/shared/libs';
|
import { request } from '~/shared/libs';
|
||||||
|
|
@ -6,7 +6,6 @@ import { useWebApp } from '@vkruglikov/react-telegram-web-app';
|
||||||
import { appendReferral } from '~/shared/utils/start-payload';
|
import { appendReferral } from '~/shared/utils/start-payload';
|
||||||
|
|
||||||
const sessionStorageKey = 'auth_v1_token';
|
const sessionStorageKey = 'auth_v1_token';
|
||||||
const tonProofStorageKey = 'stored_ton_proof';
|
|
||||||
const payloadTTLMS = 1000 * 60 * 20;
|
const payloadTTLMS = 1000 * 60 * 20;
|
||||||
|
|
||||||
export const useAuth = () => {
|
export const useAuth = () => {
|
||||||
|
|
@ -14,25 +13,6 @@ export const useAuth = () => {
|
||||||
const [tonConnectUI] = useTonConnectUI();
|
const [tonConnectUI] = useTonConnectUI();
|
||||||
const interval = useRef<ReturnType<typeof setInterval> | undefined>();
|
const interval = useRef<ReturnType<typeof setInterval> | undefined>();
|
||||||
|
|
||||||
// Store ton_proof when it becomes available
|
|
||||||
useEffect(() => {
|
|
||||||
if (
|
|
||||||
tonConnectUI.wallet?.connectItems?.tonProof &&
|
|
||||||
!('error' in tonConnectUI.wallet.connectItems.tonProof) &&
|
|
||||||
tonConnectUI.wallet.account
|
|
||||||
) {
|
|
||||||
console.log('DEBUG: Storing ton_proof for future use');
|
|
||||||
localStorage.setItem(
|
|
||||||
tonProofStorageKey,
|
|
||||||
JSON.stringify({
|
|
||||||
timestamp: Date.now(),
|
|
||||||
account: tonConnectUI.wallet.account,
|
|
||||||
proof: tonConnectUI.wallet.connectItems.tonProof.proof,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [tonConnectUI.wallet?.connectItems?.tonProof, tonConnectUI.wallet?.account]);
|
|
||||||
|
|
||||||
const makeAuthRequest = async (params: {
|
const makeAuthRequest = async (params: {
|
||||||
twa_data: string;
|
twa_data: string;
|
||||||
ton_proof?: {
|
ton_proof?: {
|
||||||
|
|
@ -62,11 +42,6 @@ export const useAuth = () => {
|
||||||
}
|
}
|
||||||
return res;
|
return res;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// If we were using ton_proof and it failed, clear stored proof
|
|
||||||
if (params.ton_proof) {
|
|
||||||
console.log('DEBUG: Auth with proof failed, clearing stored proof');
|
|
||||||
localStorage.removeItem(tonProofStorageKey);
|
|
||||||
}
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
@ -81,7 +56,6 @@ export const useAuth = () => {
|
||||||
console.log('DEBUG: Wallet selection failed with 404, disconnecting');
|
console.log('DEBUG: Wallet selection failed with 404, disconnecting');
|
||||||
await tonConnectUI.disconnect();
|
await tonConnectUI.disconnect();
|
||||||
localStorage.removeItem(sessionStorageKey);
|
localStorage.removeItem(sessionStorageKey);
|
||||||
localStorage.removeItem(tonProofStorageKey);
|
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
@ -230,94 +204,14 @@ export const useAuth = () => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Case 2: Already connected - try to use stored proof first
|
// Case 2: Already connected
|
||||||
console.log('DEBUG: Already connected');
|
console.log('DEBUG: Already connected');
|
||||||
|
// TonConnect proofs are meant for initial wallet binding; reusing old proofs
|
||||||
// Check if we have a valid stored proof
|
// commonly fails server-side (replay/unknown payload). Use TWA auth without proof.
|
||||||
const storedProofData = localStorage.getItem(tonProofStorageKey);
|
|
||||||
if (storedProofData) {
|
|
||||||
try {
|
|
||||||
const proofData = JSON.parse(storedProofData);
|
|
||||||
|
|
||||||
// Check if the proof matches current wallet and is not too old
|
|
||||||
if (tonConnectUI.wallet?.account?.address === proofData.account.address) {
|
|
||||||
console.log('DEBUG: Using stored proof');
|
|
||||||
|
|
||||||
// Try auth with stored proof but ignore errors
|
|
||||||
try {
|
|
||||||
authResult = await makeAuthRequest({
|
|
||||||
twa_data: WebApp.initData,
|
|
||||||
ton_proof: {
|
|
||||||
account: proofData.account,
|
|
||||||
ton_proof: proofData.proof,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
// If successful, remove stored proof as it's been used
|
|
||||||
localStorage.removeItem(tonProofStorageKey);
|
|
||||||
} catch (error) {
|
|
||||||
console.log(
|
|
||||||
'DEBUG: Auth with stored proof failed, proceeding without it'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fall back to auth without proof
|
|
||||||
authResult = await makeAuthRequest({
|
authResult = await makeAuthRequest({
|
||||||
twa_data: WebApp.initData,
|
twa_data: WebApp.initData,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
console.log('DEBUG: Stored proof address mismatch');
|
|
||||||
localStorage.removeItem(tonProofStorageKey);
|
|
||||||
|
|
||||||
// Auth without proof
|
|
||||||
authResult = await makeAuthRequest({
|
|
||||||
twa_data: WebApp.initData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('DEBUG: Error parsing stored proof:', error);
|
|
||||||
localStorage.removeItem(tonProofStorageKey);
|
|
||||||
|
|
||||||
// Auth without proof
|
|
||||||
authResult = await makeAuthRequest({
|
|
||||||
twa_data: WebApp.initData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// No stored proof, check if we have a live proof
|
|
||||||
if (
|
|
||||||
tonConnectUI.wallet?.connectItems?.tonProof &&
|
|
||||||
!('error' in tonConnectUI.wallet.connectItems.tonProof)
|
|
||||||
) {
|
|
||||||
console.log('DEBUG: Using live proof from wallet');
|
|
||||||
try {
|
|
||||||
// Try auth with the live proof
|
|
||||||
authResult = await makeAuthRequest({
|
|
||||||
twa_data: WebApp.initData,
|
|
||||||
ton_proof: {
|
|
||||||
account: tonConnectUI.wallet.account,
|
|
||||||
ton_proof: tonConnectUI.wallet.connectItems.tonProof.proof,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.log(
|
|
||||||
'DEBUG: Auth with live proof failed, proceeding without it'
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fall back to auth without proof
|
|
||||||
authResult = await makeAuthRequest({
|
|
||||||
twa_data: WebApp.initData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Connected without proof - already authenticated
|
|
||||||
console.log('DEBUG: Connected without proof, proceeding without it');
|
|
||||||
authResult = await makeAuthRequest({
|
|
||||||
twa_data: WebApp.initData,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Always try to select wallet after auth (this validates the connection)
|
// Always try to select wallet after auth (this validates the connection)
|
||||||
if (tonConnectUI.wallet?.account?.address) {
|
if (tonConnectUI.wallet?.account?.address) {
|
||||||
|
|
@ -332,7 +226,6 @@ export const useAuth = () => {
|
||||||
console.log('DEBUG: Connection validation failed, disconnecting');
|
console.log('DEBUG: Connection validation failed, disconnecting');
|
||||||
await tonConnectUI.disconnect();
|
await tonConnectUI.disconnect();
|
||||||
localStorage.removeItem(sessionStorageKey);
|
localStorage.removeItem(sessionStorageKey);
|
||||||
localStorage.removeItem(tonProofStorageKey);
|
|
||||||
throw new Error('Connection validation failed');
|
throw new Error('Connection validation failed');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { request } from "~/shared/libs";
|
||||||
import { Royalty } from "~/shared/stores/root";
|
import { Royalty } from "~/shared/stores/root";
|
||||||
|
|
||||||
const VIEW_CONTENT_NODE_ORIGINS = [
|
const VIEW_CONTENT_NODE_ORIGINS = [
|
||||||
"https://my-public-node-8.projscale.dev",
|
"https://my-public-node-103.projscale.dev",
|
||||||
"https://my-public-node-7.projscale.dev",
|
"https://my-public-node-7.projscale.dev",
|
||||||
"https://my-public-node-9.projscale.dev",
|
"https://my-public-node-9.projscale.dev",
|
||||||
"https://my-public-node-10.projscale.dev",
|
"https://my-public-node-10.projscale.dev",
|
||||||
|
|
|
||||||
22
yarn.lock
22
yarn.lock
|
|
@ -225,10 +225,10 @@
|
||||||
"@babel/helper-validator-identifier" "^7.22.20"
|
"@babel/helper-validator-identifier" "^7.22.20"
|
||||||
to-fast-properties "^2.0.0"
|
to-fast-properties "^2.0.0"
|
||||||
|
|
||||||
"@esbuild/darwin-arm64@0.19.12":
|
"@esbuild/linux-x64@0.19.12":
|
||||||
version "0.19.12"
|
version "0.19.12"
|
||||||
resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.19.12.tgz"
|
resolved "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.19.12.tgz"
|
||||||
integrity sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==
|
integrity sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==
|
||||||
|
|
||||||
"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0":
|
"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0":
|
||||||
version "4.4.0"
|
version "4.4.0"
|
||||||
|
|
@ -381,10 +381,15 @@
|
||||||
resolved "https://registry.npmjs.org/@remix-run/router/-/router-1.15.2.tgz"
|
resolved "https://registry.npmjs.org/@remix-run/router/-/router-1.15.2.tgz"
|
||||||
integrity sha512-+Rnav+CaoTE5QJc4Jcwh5toUpnVLKYbpU6Ys0zqbakqbaLQHeglLVHPfxOiQqdNmUy5C2lXz5dwC6tQNX2JW2Q==
|
integrity sha512-+Rnav+CaoTE5QJc4Jcwh5toUpnVLKYbpU6Ys0zqbakqbaLQHeglLVHPfxOiQqdNmUy5C2lXz5dwC6tQNX2JW2Q==
|
||||||
|
|
||||||
"@rollup/rollup-darwin-arm64@4.12.0":
|
"@rollup/rollup-linux-x64-gnu@4.12.0":
|
||||||
version "4.12.0"
|
version "4.12.0"
|
||||||
resolved "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.12.0.tgz"
|
resolved "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.12.0.tgz"
|
||||||
integrity sha512-X64tZd8dRE/QTrBIEs63kaOBG0b5GVEd3ccoLtyf6IdXtHdh8h+I56C2yC3PtC9Ucnv0CpNFJLqKFVgCYe0lOQ==
|
integrity sha512-TenQhZVOtw/3qKOPa7d+QgkeM6xY0LtwzR8OplmyL5LrgTWIXpTQg2Q2ycBf8jm+SFW2Wt/DTn1gf7nFp3ssVA==
|
||||||
|
|
||||||
|
"@rollup/rollup-linux-x64-musl@4.12.0":
|
||||||
|
version "4.12.0"
|
||||||
|
resolved "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.12.0.tgz"
|
||||||
|
integrity sha512-LfFdRhNnW0zdMvdCb5FNuWlls2WbbSridJvxOvYWgSBOYZtgBfW9UGNJG//rwMqTX1xQE9BAodvMH9tAusKDUw==
|
||||||
|
|
||||||
"@sentry-internal/browser-utils@9.1.0":
|
"@sentry-internal/browser-utils@9.1.0":
|
||||||
version "9.1.0"
|
version "9.1.0"
|
||||||
|
|
@ -1655,11 +1660,6 @@ fs.realpath@^1.0.0:
|
||||||
resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz"
|
resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz"
|
||||||
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
|
integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==
|
||||||
|
|
||||||
fsevents@~2.3.2, fsevents@~2.3.3:
|
|
||||||
version "2.3.3"
|
|
||||||
resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz"
|
|
||||||
integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
|
|
||||||
|
|
||||||
function-bind@^1.1.2:
|
function-bind@^1.1.2:
|
||||||
version "1.1.2"
|
version "1.1.2"
|
||||||
resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz"
|
resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue