From 5ef8c35ca8cfb1d83eeb7429014e493ccaa627ce Mon Sep 17 00:00:00 2001 From: root Date: Sun, 26 Oct 2025 11:14:49 +0000 Subject: [PATCH] update --- src/app/router/index.tsx | 4 + src/pages/admin/config.ts | 6 +- src/pages/admin/sections/Network.tsx | 329 +++++++++++++++++++ src/pages/admin/sections/NetworkSettings.tsx | 71 ++++ src/pages/admin/sections/index.ts | 2 + src/pages/view-content/index.tsx | 6 +- src/shared/services/admin/index.ts | 72 ++++ yarn.lock | 22 +- 8 files changed, 497 insertions(+), 15 deletions(-) create mode 100644 src/pages/admin/sections/Network.tsx create mode 100644 src/pages/admin/sections/NetworkSettings.tsx diff --git a/src/app/router/index.tsx b/src/app/router/index.tsx index 5151273..4ce9586 100644 --- a/src/app/router/index.tsx +++ b/src/app/router/index.tsx @@ -16,6 +16,8 @@ import { AdminBlockchainPage, AdminNodesPage, AdminStatusPage, + AdminNetworkPage, + AdminNetworkSettingsPage, } from "~/pages/admin/sections"; import { ProtectedLayout } from "./protected-layout"; @@ -59,6 +61,8 @@ const router = createBrowserRouter([ { path: "blockchain", element: }, { path: "nodes", element: }, { path: "status", element: }, + { path: "network", element: }, + { path: "network-settings", element: }, ], }, ]); diff --git a/src/pages/admin/config.ts b/src/pages/admin/config.ts index c7675bb..fc79aa5 100644 --- a/src/pages/admin/config.ts +++ b/src/pages/admin/config.ts @@ -11,7 +11,9 @@ export type AdminSectionId = | "system" | "blockchain" | "nodes" - | "status"; + | "status" + | "network" + | "network-settings"; export type AdminSection = { id: AdminSectionId; @@ -33,6 +35,8 @@ export const ADMIN_SECTIONS: AdminSection[] = [ { id: "blockchain", label: "Блокчейн", description: "История задач и метрики блокчейн-интеграции", path: "blockchain" }, { id: "nodes", label: "Ноды", description: "Роли, версии и последнее появление узлов", path: "nodes" }, { id: "status", label: "Статус & лимиты", description: "IPFS, очереди и лимиты синхронизации", path: "status" }, + { id: "network", label: "Состояние сети", description: "Мониторинг децентрализованного слоя и репликаций", path: "network" }, + { id: "network-settings", label: "Сеть → Настройки", description: "Интервалы heartbeat/gossip и бэк-офф", path: "network-settings" }, ]; export const DEFAULT_ADMIN_SECTION = ADMIN_SECTIONS[0]; diff --git a/src/pages/admin/sections/Network.tsx b/src/pages/admin/sections/Network.tsx new file mode 100644 index 0000000..c49a0e0 --- /dev/null +++ b/src/pages/admin/sections/Network.tsx @@ -0,0 +1,329 @@ +import React, { useMemo, useState } from "react"; +import { useQuery } from "react-query"; +import { request } from "~/shared/libs/request"; + +const ratioTone = (v: number) => { + if (v >= 0.9) return "text-emerald-400"; + if (v >= 0.6) return "text-yellow-400"; + return "text-red-400"; +}; + +export const AdminNetworkPage: React.FC = () => { + const [filterText, setFilterText] = useState(""); + const [reachabilityFilter, setReachabilityFilter] = useState<"all" | "healthy" | "islands">("all"); + const [sortBy, setSortBy] = useState<"leases" | "leaderships" | "reachability">("leases"); + const [roleFilter, setRoleFilter] = useState<"all" | "trusted" | "read-only" | "deny">("all"); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(25); + + const { data, isLoading, error, refetch } = useQuery( + ["admin", "network", page, pageSize], + async () => { + const { data } = await request.get("/admin.network", { params: { page, page_size: pageSize } }); + return data as any; + }, + { keepPreviousData: true }, + ); + + const members = data?.members ?? []; + const replication = data?.per_node_replication ?? {}; + const summary = data?.summary; + const receipts = data?.receipts ?? []; + + const [selectedNode, setSelectedNode] = useState(null); + + const total = data?.paging?.total ?? (data?.members?.length ?? 0); + const totalPages = Math.max(1, Math.ceil(total / pageSize)); + + const columns = [ + { key: "node_id", label: "NodeID" }, + { key: "public_host", label: "Публичный адрес" }, + { key: "version", label: "Версия" }, + { key: "role", label: "Роль" }, + { key: "ip", label: "IP" }, + { key: "asn", label: "ASN" }, + { key: "reachability_ratio", label: "Достижимость" }, + { key: "leases", label: "Реплики" }, + { key: "leaderships", label: "Лидерства" }, + { key: "receipts", label: "Квитанции" }, + ]; + + const rows = useMemo(() => { + let list = members.map((m: any) => { + const p = replication[m.node_id] ?? { leases_held: 0, leaderships: 0, sample_contents: [] }; + return { + ...m, + leases: p.leases_held, + leaderships: p.leaderships, + _samples: p.sample_contents, + }; + }); + // filter by text + if (filterText.trim()) { + const t = filterText.trim().toLowerCase(); + list = list.filter((r: any) => + (r.public_host ?? "").toLowerCase().includes(t) || + (r.ip ?? "").toLowerCase().includes(t) || + (r.node_id ?? "").toLowerCase().includes(t) + ); + } + // role filter + if (roleFilter !== "all") { + list = list.filter((r: any) => (r.role ?? "read-only") === roleFilter); + } + // reachability filter + if (reachabilityFilter === "healthy") { + list = list.filter((r: any) => r.reachability_ratio >= 0.6); + } else if (reachabilityFilter === "islands") { + list = list.filter((r: any) => r.reachability_ratio < 0.6); + } + // sorting + list.sort((a: any, b: any) => { + if (sortBy === "leases") return b.leases - a.leases; + if (sortBy === "leaderships") return b.leaderships - a.leaderships; + return b.reachability_ratio - a.reachability_ratio; + }); + // reset page if filters reduce list + return list; + }, [members, replication, filterText, reachabilityFilter, sortBy, roleFilter]); + const paged = rows; // сервер уже порезал список + + if (isLoading) return
Загрузка сети…
; + if (error) return
Ошибка загрузки: {String(error)}
; + + return ( +
+
+
+
Оценка размера сети
+
{summary?.n_estimate ?? 0}
+
+
+
Оценка trusted‑сети
+
{summary?.n_estimate_trusted ?? 0}
+
+
+
Активные (все)
+
{summary?.active ?? 0}
+
+
+
Активные (trusted)
+
{summary?.active_trusted ?? 0}
+
+
+
Острова
+
{summary?.islands ?? 0}
+
+
+
Конфликты репликаций
+
Недобор: {summary?.replication_conflicts.under ?? 0}
+
Перебор: {summary?.replication_conflicts.over ?? 0}
+
+
+
Gossip/Backoff
+
Интервал: {summary?.config?.gossip_interval_sec ?? '-'} c
+
База: {summary?.config?.gossip_backoff_base_sec ?? '-'} c
+
Потолок: {summary?.config?.gossip_backoff_cap_sec ?? '-' } c
+
+
+ +
+
+
+
Узлы сети
+ setFilterText(e.target.value)} + /> + + + + +
+ +
+
+ + + + {columns.map((c) => ( + + ))} + + + + {paged.map((r: any) => ( + setSelectedNode(r)}> + + + + + + + + + + + + ))} + +
{c.label}
{r.node_id.slice(0, 10)}…{r.public_host ?? '-'}{r.version ?? '-'}{r.role}{r.ip ?? '-'}{r.asn ?? '-'}{(r.reachability_ratio * 100).toFixed(0)}%{r.leases}{r.leaderships}{r.receipts_asn_unique ?? 0}/{r.receipts_total ?? 0}
+
+ {/* Pagination controls */} +
+
Стр. {page} из {totalPages} • Всего: {total}
+
+ + +
+
+
+ + {/* Reachability receipts table */} +
+
+
Квитанции достижимости
+
{receipts.length} шт.
+
+
+ + + + + + + + + + + {receipts.map((r: any, i: number) => ( + + + + + + + ))} + +
TargetIssuerASNСтатус
{r.target_id.slice(0, 10)}…{r.issuer_id.slice(0, 10)}…{r.asn ?? '-'} + {r.status === 'valid' && подпись ок} + {r.status === 'bad_signature' && ошибка подписи} + {r.status === 'unknown_issuer' && неизвестный эмитент} + {r.status === 'mismatch_node_id' && node_id≠pubkey} + {r.status === 'unknown' && } +
+
+
+ +
+ Подсказка: недобор/перебор репликаций сигнализируют о проблемах с диверсификацией или подсчётом N_estimate. +
+ + {selectedNode && ( +
+
+
+
Детали узла
+ +
+
+
NodeID: {selectedNode.node_id}
+
Публичный адрес: {selectedNode.public_host ?? '—'}
+
+
Версия: {selectedNode.version ?? '—'}
+
Роль: {selectedNode.role}
+
IP: {selectedNode.ip ?? '—'}
+
ASN: {selectedNode.asn ?? '—'}
+
Достижимость: {(selectedNode.reachability_ratio * 100).toFixed(0)}%
+
Квитанции: {selectedNode.receipts_asn_unique ?? 0}/{selectedNode.receipts_total ?? 0}
+
Реплики: {selectedNode.leases}
+
Лидерства: {selectedNode.leaderships}
+
+
+
Примеры контента:
+
+ {(selectedNode._samples ?? []).map((c: string) => ( + {c.slice(0,10)}… + ))} + {(!selectedNode._samples || selectedNode._samples.length === 0) && ( + + )} +
+
+
+
Конфликты:
+
+ + + + + + + + + + {(selectedNode.conflict_samples ?? []).map((item: any, idx: number) => ( + + + + + + ))} + {(!selectedNode.conflict_samples || selectedNode.conflict_samples.length === 0) && ( + + )} + +
ТипВремяContent
{item.type}{item.ts ? new Date(item.ts * 1000).toLocaleString() : '—'}{(item.content_id || '').slice(0, 10)}…
Нет данных
+
+
+ {selectedNode.public_host && ( + + )} +
+
+
+ )} +
+ ); +}; + +export default AdminNetworkPage; diff --git a/src/pages/admin/sections/NetworkSettings.tsx b/src/pages/admin/sections/NetworkSettings.tsx new file mode 100644 index 0000000..eea7e24 --- /dev/null +++ b/src/pages/admin/sections/NetworkSettings.tsx @@ -0,0 +1,71 @@ +import { useEffect, useState } from "react"; +import { request } from "~/shared/libs/request"; + +type Config = { + heartbeat_interval: number; + lease_ttl: number; + gossip_interval_sec: number; + gossip_backoff_base_sec: number; + gossip_backoff_cap_sec: number; +}; + +export const AdminNetworkSettingsPage: React.FC = () => { + const [cfg, setCfg] = useState(null); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const load = async () => { + try { + const { data } = await request.get("/admin.network.config"); + setCfg(data.config); + } catch (e: any) { + setError(String(e?.message || e)); + } + }; + useEffect(() => { void load(); }, []); + + const update = async () => { + if (!cfg) return; + setSaving(true); + setError(null); + try { + await request.post("/admin.network.config.set", cfg); + await load(); + } catch (e: any) { + setError(String(e?.message || e)); + } finally { + setSaving(false); + } + }; + + if (!cfg) return
Загрузка настроек…
; + + const Field: React.FC<{ label: string; value: number; onChange: (v:number)=>void; tip?: string }> = ({label, value, onChange, tip}) => ( + + ); + + return ( +
+
Сеть → Настройки
+ {error &&
Ошибка: {error}
} +
+ setCfg({...cfg, heartbeat_interval: v})} tip="Период обновления лизов" /> + setCfg({...cfg, lease_ttl: v})} tip="Время жизни лиза до истечения" /> + setCfg({...cfg, gossip_interval_sec: v})} tip="Период рассылки снимка DHT" /> + setCfg({...cfg, gossip_backoff_base_sec: v})} tip="Начальный бэк-офф для неуспешных пиров" /> + setCfg({...cfg, gossip_backoff_cap_sec: v})} tip="Максимальный бэк-офф" /> +
+
+ + +
+
Примечание: некоторые параметры читаются задачами-демонами при каждом цикле и применяются без перезапуска.
+
+ ); +}; + +export default AdminNetworkSettingsPage; + diff --git a/src/pages/admin/sections/index.ts b/src/pages/admin/sections/index.ts index 806877d..c30024a 100644 --- a/src/pages/admin/sections/index.ts +++ b/src/pages/admin/sections/index.ts @@ -9,3 +9,5 @@ export { AdminSystemPage } from "./System"; export { AdminBlockchainPage } from "./Blockchain"; export { AdminNodesPage } from "./Nodes"; export { AdminStatusPage } from "./Status"; +export { AdminNetworkPage } from "./Network"; +export { AdminNetworkSettingsPage } from "./NetworkSettings"; diff --git a/src/pages/view-content/index.tsx b/src/pages/view-content/index.tsx index 3a6eeb0..7d195a3 100644 --- a/src/pages/view-content/index.tsx +++ b/src/pages/view-content/index.tsx @@ -340,17 +340,17 @@ export const ViewContentPage = () => { /> )} {!haveLicense && ( -
+