events & global sync. unstable
This commit is contained in:
parent
997fbb6985
commit
3b31c4e6cb
|
|
@ -9,6 +9,7 @@ import {
|
||||||
AdminStoragePage,
|
AdminStoragePage,
|
||||||
AdminUploadsPage,
|
AdminUploadsPage,
|
||||||
AdminUsersPage,
|
AdminUsersPage,
|
||||||
|
AdminEventsPage,
|
||||||
AdminLicensesPage,
|
AdminLicensesPage,
|
||||||
AdminStarsPage,
|
AdminStarsPage,
|
||||||
AdminSystemPage,
|
AdminSystemPage,
|
||||||
|
|
@ -50,6 +51,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 /> },
|
||||||
|
|
|
||||||
|
|
@ -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,25 +60,28 @@ 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}
|
||||||
>
|
>
|
||||||
<svg
|
{children ?? (
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
<svg
|
||||||
className="h-4 w-4"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
viewBox="0 0 24 24"
|
className="h-4 w-4"
|
||||||
fill="none"
|
viewBox="0 0 24 24"
|
||||||
stroke="currentColor"
|
fill="none"
|
||||||
strokeWidth="1.5"
|
stroke="currentColor"
|
||||||
strokeLinecap="round"
|
strokeWidth="1.5"
|
||||||
strokeLinejoin="round"
|
strokeLinecap="round"
|
||||||
>
|
strokeLinejoin="round"
|
||||||
<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" />
|
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
|
||||||
</svg>
|
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ export type AdminSectionId =
|
||||||
| "overview"
|
| "overview"
|
||||||
| "storage"
|
| "storage"
|
||||||
| "uploads"
|
| "uploads"
|
||||||
|
| "events"
|
||||||
| "users"
|
| "users"
|
||||||
| "licenses"
|
| "licenses"
|
||||||
| "stars"
|
| "stars"
|
||||||
|
|
@ -24,6 +25,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" },
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,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 +204,7 @@ export type AdminUploadsContent = {
|
||||||
ipfs: AdminUploadsContentIpfs;
|
ipfs: AdminUploadsContentIpfs;
|
||||||
stored: AdminUploadsContentStored;
|
stored: AdminUploadsContentStored;
|
||||||
links: AdminUploadsContentLinks;
|
links: AdminUploadsContentLinks;
|
||||||
|
distribution: AdminContentDistribution;
|
||||||
flags?: AdminUploadsContentFlags;
|
flags?: AdminUploadsContentFlags;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -264,6 +294,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 +314,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 +543,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 = {
|
||||||
|
|
@ -773,6 +849,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 +1055,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();
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue