events & global sync. unstable
This commit is contained in:
parent
997fbb6985
commit
3b31c4e6cb
|
|
@ -9,6 +9,7 @@ import {
|
|||
AdminStoragePage,
|
||||
AdminUploadsPage,
|
||||
AdminUsersPage,
|
||||
AdminEventsPage,
|
||||
AdminLicensesPage,
|
||||
AdminStarsPage,
|
||||
AdminSystemPage,
|
||||
|
|
@ -50,6 +51,7 @@ const router = createBrowserRouter([
|
|||
{ path: "overview", element: <AdminOverviewPage /> },
|
||||
{ path: "storage", element: <AdminStoragePage /> },
|
||||
{ path: "uploads", element: <AdminUploadsPage /> },
|
||||
{ path: "events", element: <AdminEventsPage /> },
|
||||
{ path: "users", element: <AdminUsersPage /> },
|
||||
{ path: "licenses", element: <AdminLicensesPage /> },
|
||||
{ path: "stars", element: <AdminStarsPage /> },
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback } from "react";
|
||||
import { ReactNode, useCallback } from "react";
|
||||
import clsx from "clsx";
|
||||
|
||||
import { useAdminContext } from "../context";
|
||||
|
|
@ -8,6 +8,7 @@ type CopyButtonProps = {
|
|||
className?: string;
|
||||
"aria-label"?: string;
|
||||
successMessage?: string;
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
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 handleCopy = useCallback(async () => {
|
||||
|
|
@ -59,25 +60,28 @@ export const CopyButton = ({ value, className, "aria-label": ariaLabel, successM
|
|||
<button
|
||||
type="button"
|
||||
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,
|
||||
)}
|
||||
aria-label={ariaLabel ?? "Скопировать значение"}
|
||||
onClick={handleCopy}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
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" />
|
||||
</svg>
|
||||
{children ?? (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
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" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ export type AdminSectionId =
|
|||
| "overview"
|
||||
| "storage"
|
||||
| "uploads"
|
||||
| "events"
|
||||
| "users"
|
||||
| "licenses"
|
||||
| "stars"
|
||||
|
|
@ -24,6 +25,7 @@ export const ADMIN_SECTIONS: AdminSection[] = [
|
|||
{ id: "overview", label: "Обзор", description: "Краткий срез состояния узла, окружения и служб", path: "overview" },
|
||||
{ id: "storage", label: "Хранилище", description: "Загруженность диска и директории контента", path: "storage" },
|
||||
{ id: "uploads", label: "Загрузки", description: "Отслеживание статуса загрузок и деривативов", path: "uploads" },
|
||||
{ id: "events", label: "События", description: "Журнал действий ноды и сети", path: "events" },
|
||||
{ id: "users", label: "Пользователи", description: "Мониторинг пользователей, кошельков и активности", path: "users" },
|
||||
{ id: "licenses", label: "Лицензии", description: "Статус лицензий и фильтрация по типам", path: "licenses" },
|
||||
{ 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 { Section, PaginationControls, Badge } from "../components";
|
||||
|
|
@ -7,7 +8,12 @@ import { formatDate, formatStars, numberFormatter } from "../utils/format";
|
|||
|
||||
export const AdminStarsPage = () => {
|
||||
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 [typeFilter, setTypeFilter] = useState("all");
|
||||
const [page, setPage] = useState(0);
|
||||
|
|
@ -31,6 +37,10 @@ export const AdminStarsPage = () => {
|
|||
},
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSearch(initialSearch);
|
||||
}, [initialSearch]);
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0);
|
||||
}, [normalizedSearch, paidFilter, typeFilter]);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useAdminSystem } from "~/shared/services/admin";
|
||||
import { Section, InfoRow, Badge } from "../components";
|
||||
import { Section, InfoRow, Badge, CopyButton } from "../components";
|
||||
import { useAdminContext } from "../context";
|
||||
import { formatDate, formatUnknown, numberFormatter } from "../utils/format";
|
||||
|
||||
|
|
@ -20,7 +20,8 @@ export const AdminSystemPage = () => {
|
|||
) : 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 (
|
||||
<Section id="system" title="Система" description="Ключевые переменные и внутренние конфиги">
|
||||
|
|
@ -35,6 +36,43 @@ export const AdminSystemPage = () => {
|
|||
))}
|
||||
</dl>
|
||||
</div>
|
||||
<div className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-400">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">
|
||||
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-400">Задачи блокчейна</h3>
|
||||
<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 { useLocation } from "react-router-dom";
|
||||
|
||||
import {
|
||||
useAdminUploads,
|
||||
|
|
@ -118,6 +119,9 @@ const classifyUpload = (item: AdminUploadsContent): UploadDecoration => {
|
|||
const user = item.stored.user;
|
||||
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
|
||||
.filter((value) => value !== null && value !== undefined && `${value}`.length > 0)
|
||||
.map((value) => `${value}`.toLowerCase())
|
||||
|
|
@ -166,6 +170,7 @@ const UploadCard = ({ decoration }: { decoration: UploadDecoration }) => {
|
|||
const { item, flags } = decoration;
|
||||
const problemMessages = decoration.hasIssue ? getProblemMessages(item) : [];
|
||||
const derivativeDownloads = item.links.download_derivatives ?? [];
|
||||
const distributionNodes = item.distribution?.nodes ?? [];
|
||||
|
||||
return (
|
||||
<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>
|
||||
|
||||
{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">
|
||||
<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">
|
||||
|
|
@ -397,8 +475,17 @@ const UploadCard = ({ decoration }: { decoration: UploadDecoration }) => {
|
|||
|
||||
export const AdminUploadsPage = () => {
|
||||
const { isAuthorized, handleRequestError } = useAdminContext();
|
||||
const location = useLocation();
|
||||
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 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 { Section, PaginationControls, CopyButton } from "../components";
|
||||
import { useAdminUsers, useAdminSetUserAdmin } from "~/shared/services/admin";
|
||||
import { Section, PaginationControls, CopyButton, Badge } from "../components";
|
||||
import { useAdminContext } from "../context";
|
||||
import { formatDate, formatStars, numberFormatter } from "../utils/format";
|
||||
|
||||
export const AdminUsersPage = () => {
|
||||
const { isAuthorized, handleRequestError } = useAdminContext();
|
||||
const [search, setSearch] = useState("");
|
||||
const { isAuthorized, handleRequestError, pushFlash } = useAdminContext();
|
||||
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 [pendingUserId, setPendingUserId] = useState<number | null>(null);
|
||||
|
||||
const limit = 50;
|
||||
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(() => {
|
||||
setPage(0);
|
||||
}, [normalizedSearch]);
|
||||
|
|
@ -54,6 +88,11 @@ export const AdminUsersPage = () => {
|
|||
value: numberFormatter.format(total),
|
||||
helper: `На странице: ${numberFormatter.format(summary.users_returned)}`,
|
||||
},
|
||||
{
|
||||
label: "Администраторы",
|
||||
value: numberFormatter.format(summary.admins_total ?? 0),
|
||||
helper: summary.admins_total ? "Имеют доступ к /admin" : "Назначьте администраторов",
|
||||
},
|
||||
{
|
||||
label: "Кошельки (активные/всего)",
|
||||
value: `${numberFormatter.format(summary.wallets_active)} / ${numberFormatter.format(summary.wallets_total)}`,
|
||||
|
|
@ -165,6 +204,23 @@ export const AdminUsersPage = () => {
|
|||
{(user.first_name ?? "")} {(user.last_name ?? "")}
|
||||
</div>
|
||||
) : 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 className="px-3 py-3 align-top">
|
||||
<div className="text-xs text-slate-400">
|
||||
|
|
@ -291,6 +347,26 @@ export const AdminUsersPage = () => {
|
|||
{numberFormatter.format(user.licenses.active)}
|
||||
</span>
|
||||
</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">
|
||||
<span className="text-slate-500">Последняя активность</span>
|
||||
{user.ip_activity.last ? (
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
export { AdminOverviewPage } from "./Overview";
|
||||
export { AdminStoragePage } from "./Storage";
|
||||
export { AdminUploadsPage } from "./Uploads";
|
||||
export { AdminEventsPage } from "./Events";
|
||||
export { AdminUsersPage } from "./Users";
|
||||
export { AdminLicensesPage } from "./Licenses";
|
||||
export { AdminStarsPage } from "./Stars";
|
||||
|
|
|
|||
|
|
@ -143,6 +143,35 @@ export type AdminUploadsContentFlags = {
|
|||
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 = {
|
||||
upload_state: string | null;
|
||||
conversion_state: string | null;
|
||||
|
|
@ -175,6 +204,7 @@ export type AdminUploadsContent = {
|
|||
ipfs: AdminUploadsContentIpfs;
|
||||
stored: AdminUploadsContentStored;
|
||||
links: AdminUploadsContentLinks;
|
||||
distribution: AdminContentDistribution;
|
||||
flags?: AdminUploadsContentFlags;
|
||||
};
|
||||
|
||||
|
|
@ -264,6 +294,7 @@ export type AdminUserIpActivity = {
|
|||
|
||||
export type AdminUserItem = {
|
||||
id: number;
|
||||
is_admin: boolean;
|
||||
telegram_id: number;
|
||||
username: string | null;
|
||||
first_name: string | null;
|
||||
|
|
@ -283,8 +314,48 @@ export type AdminUserItem = {
|
|||
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 = {
|
||||
users_returned: number;
|
||||
admins_total: number;
|
||||
wallets_total: number;
|
||||
wallets_active: number;
|
||||
licenses_total: number;
|
||||
|
|
@ -472,6 +543,11 @@ export type AdminSystemResponse = {
|
|||
encrypted_cid: string | null;
|
||||
updated_at: string;
|
||||
}>;
|
||||
telegram_bots: Array<{
|
||||
role: string;
|
||||
username: string;
|
||||
url: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
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 = (
|
||||
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 = () => {
|
||||
const cacheSetLimits = useAdminCacheSetLimits();
|
||||
const syncSetLimits = useAdminSyncSetLimits();
|
||||
|
|
|
|||
Loading…
Reference in New Issue