eventsQuery.refetch()}
+ disabled={eventsQuery.isFetching}
+ >
+ {eventsQuery.isFetching ? "Обновляем…" : "Обновить"}
+
+ }
+ >
+
+
+
Всего событий
+
{numberFormatter.format(total)}
+
На странице {numberFormatter.format(events.length)}
+
+
+
Типы
+
{Object.keys(availableFilters.types).length}
+
Фильтр по типу помогает сузить список
+
+
+
Статусы
+
{Object.keys(availableFilters.statuses).length}
+
Например, только требующие действий
+
+
+
Источники
+
{Object.keys(availableFilters.origins).length}
+
Отслеживайте trusted-нод
+
+
+
+
+
+
+ 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"
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {eventsQuery.isLoading && !eventsQuery.data ? (
+
+ Загружаем события…
+
+ ) : null}
+
+
+ {events.length === 0 ? (
+
+ События не найдены.
+
+ ) : (
+ 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 (
+
+
+
+
+ #{numberFormatter.format(event.seq)}
+ {typeLabel}
+ {event.status}
+
+
+ UID: {event.uid}
+
+
+
+
+
Создано: {formatDate(event.created_at)}
+
Получено: {formatDate(event.received_at)}
+
Применено: {formatDate(event.applied_at)}
+
+
+
+
+
Источник
+
+ {event.origin_public_key ?? "—"}
+ {event.origin_public_key ? (
+
+ ) : null}
+
+
{event.origin_host ?? "—"}
+
+
+
Ссылки
+ {linkEntries.length === 0 ? (
+
Нет прямых ссылок
+ ) : (
+
+ {linkEntries.map(([key, url]) => renderLink(key, url))}
+
+ )}
+
+
+
+ Показать payload
+
+ {JSON.stringify(event.payload ?? {}, null, 2)}
+
+
+
+ );
+ })
+ )}
+
+
+
+
+ );
+};
diff --git a/src/pages/admin/sections/Stars.tsx b/src/pages/admin/sections/Stars.tsx
index 535916a..49421c1 100644
--- a/src/pages/admin/sections/Stars.tsx
+++ b/src/pages/admin/sections/Stars.tsx
@@ -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]);
diff --git a/src/pages/admin/sections/System.tsx b/src/pages/admin/sections/System.tsx
index 56397e0..828ec42 100644
--- a/src/pages/admin/sections/System.tsx
+++ b/src/pages/admin/sections/System.tsx
@@ -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 (
@@ -35,6 +36,43 @@ export const AdminSystemPage = () => {
))}
+
+
Telegram-боты
+ {bots.length === 0 ? (
+
Боты не настроены.
+ ) : (
+
+ )}
+
Задачи блокчейна
diff --git a/src/pages/admin/sections/Uploads.tsx b/src/pages/admin/sections/Uploads.tsx
index 25a2722..91e487c 100644
--- a/src/pages/admin/sections/Uploads.tsx
+++ b/src/pages/admin/sections/Uploads.tsx
@@ -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 (
@@ -235,6 +240,79 @@ const UploadCard = ({ decoration }: { decoration: UploadDecoration }) => {
+ {distributionNodes.length > 0 ? (
+
+
Синхронизация
+
+ {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 (
+ -
+
+
+
+ {nodeTitle}
+ {node.is_local ? "локально" : "удаленно"}
+ {node.role ? {node.role} : null}
+
+
+ {sizeLabel ?
Размер: {sizeLabel}
: null}
+ {updatedLabel ?
Обновлено: {updatedLabel}
: null}
+ {lastSeenLabel && !node.is_local ?
Нода активна: {lastSeenLabel}
: null}
+ {node.version ?
Версия: {node.version}
: null}
+
+
+
+ {node.links.web_view ? (
+
+ Web
+
+ ) : null}
+ {node.links.api_view ? (
+ <>
+
+ API
+
+
+ копировать API
+
+ >
+ ) : null}
+ {node.links.gateway_view ? (
+
+ Gateway
+
+ ) : null}
+
+
+
+ );
+ })}
+
+
+ ) : null}
+
История загрузки
@@ -397,8 +475,17 @@ const UploadCard = ({ decoration }: { decoration: UploadDecoration }) => {
export const AdminUploadsPage = () => {
const { isAuthorized, handleRequestError } = useAdminContext();
+ const location = useLocation();
const [uploadsFilter, setUploadsFilter] = useState("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;
diff --git a/src/pages/admin/sections/Users.tsx b/src/pages/admin/sections/Users.tsx
index cc9fb5e..ac46988 100644
--- a/src/pages/admin/sections/Users.tsx
+++ b/src/pages/admin/sections/Users.tsx
@@ -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(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 ?? "")}
) : null}
+
+
+ {user.is_admin ? "Администратор" : "Пользователь"}
+
+
+
@@ -291,6 +347,26 @@ export const AdminUsersPage = () => {
{numberFormatter.format(user.licenses.active)}
+
+ Роль
+
+
+ {user.is_admin ? "Администратор" : "Пользователь"}
+
+
+
+
Последняя активность
{user.ip_activity.last ? (
diff --git a/src/pages/admin/sections/index.ts b/src/pages/admin/sections/index.ts
index 0e1c0e1..806877d 100644
--- a/src/pages/admin/sections/index.ts
+++ b/src/pages/admin/sections/index.ts
@@ -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";
diff --git a/src/shared/services/admin/index.ts b/src/shared/services/admin/index.ts
index b94b3b1..fe7fb2d 100644
--- a/src/shared/services/admin/index.ts
+++ b/src/shared/services/admin/index.ts
@@ -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 ;
+ links: Record;
+};
+
+export type AdminEventsResponse = {
+ total: number;
+ limit: number;
+ offset: number;
+ filters?: Record;
+ items: AdminEventItem[];
+ available_filters: {
+ types: Record;
+ statuses: Record;
+ origins: Record;
+ };
+};
+
+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,
+) => {
+ 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 = {
+ limit: params?.limit,
+ offset: params?.offset,
+ search: normalizedSearch ?? undefined,
+ type: typeValue ?? undefined,
+ status: statusValue ?? undefined,
+ origin: originValue ?? undefined,
+ };
+
+ return useQuery(
+ ['admin', 'events', paramsKey],
+ async () => {
+ const { data } = await request.get('/admin.events', {
+ params: queryParams,
+ });
+ return data;
+ },
+ {
+ ...defaultQueryOptions,
+ ...options,
+ },
+ );
+};
+
export const useAdminSystem = (
options?: QueryOptions,
) => {
@@ -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();
|