diff --git a/src/pages/admin/components/CopyButton.tsx b/src/pages/admin/components/CopyButton.tsx new file mode 100644 index 0000000..0e51d9c --- /dev/null +++ b/src/pages/admin/components/CopyButton.tsx @@ -0,0 +1,83 @@ +import { useCallback } from "react"; +import clsx from "clsx"; + +import { useAdminContext } from "../context"; + +type CopyButtonProps = { + value: string | number; + className?: string; + "aria-label"?: string; + successMessage?: string; +}; + +const copyFallback = (text: string) => { + if (typeof document === "undefined") { + throw new Error("Clipboard API недоступен"); + } + const textarea = document.createElement("textarea"); + textarea.value = text; + textarea.style.position = "fixed"; + textarea.style.top = "-1000px"; + textarea.style.left = "-1000px"; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + try { + document.execCommand("copy"); + } finally { + document.body.removeChild(textarea); + } +}; + +export const CopyButton = ({ value, className, "aria-label": ariaLabel, successMessage }: CopyButtonProps) => { + const { pushFlash } = useAdminContext(); + + const handleCopy = useCallback(async () => { + const text = String(value ?? ""); + if (!text) { + return; + } + try { + if (typeof navigator !== "undefined" && navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(text); + } else { + copyFallback(text); + } + pushFlash({ + type: "success", + message: successMessage ?? "Значение скопировано в буфер обмена", + }); + } catch (error) { + pushFlash({ + type: "error", + message: error instanceof Error ? `Не удалось скопировать: ${error.message}` : "Не удалось скопировать значение", + }); + } + }, [pushFlash, successMessage, value]); + + return ( + + ); +}; diff --git a/src/pages/admin/components/InfoRow.tsx b/src/pages/admin/components/InfoRow.tsx index e041dd9..b1e2674 100644 --- a/src/pages/admin/components/InfoRow.tsx +++ b/src/pages/admin/components/InfoRow.tsx @@ -1,10 +1,38 @@ import type { ReactNode } from "react"; +import clsx from "clsx"; + +import { CopyButton } from "./CopyButton"; + +type InfoRowProps = { + label: string; + children: ReactNode; + copyValue?: string | number | null; + copyMessage?: string; + className?: string; +}; + +export const InfoRow = ({ label, children, copyValue, copyMessage, className }: InfoRowProps) => { + const canCopy = copyValue !== null && copyValue !== undefined && `${copyValue}`.length > 0; -export const InfoRow = ({ label, children }: { label: string; children: ReactNode }) => { return ( -
+
{label} - {children} +
+
{children}
+ {canCopy ? ( + + ) : null} +
); }; diff --git a/src/pages/admin/components/Section.tsx b/src/pages/admin/components/Section.tsx index 5bc62e5..806a0e1 100644 --- a/src/pages/admin/components/Section.tsx +++ b/src/pages/admin/components/Section.tsx @@ -15,16 +15,16 @@ export const Section = ({ id, title, description, actions, children, className }
-
+

{title}

- {description ?

{description}

: null} + {description ?

{description}

: null}
- {actions ?
{actions}
: null} + {actions ?
{actions}
: null}
{children}
diff --git a/src/pages/admin/components/index.ts b/src/pages/admin/components/index.ts index f0b33f1..66bc792 100644 --- a/src/pages/admin/components/index.ts +++ b/src/pages/admin/components/index.ts @@ -2,3 +2,4 @@ export * from "./Section"; export * from "./Badge"; export * from "./PaginationControls"; export * from "./InfoRow"; +export * from "./CopyButton"; diff --git a/src/pages/admin/index.tsx b/src/pages/admin/index.tsx index 3d637f5..e3b7a2f 100644 --- a/src/pages/admin/index.tsx +++ b/src/pages/admin/index.tsx @@ -30,20 +30,27 @@ const AdminFlash = ({ flash, onClose }: { flash: FlashMessage | null; onClose: ( } const tone = flash.type === "success" - ? "border-emerald-500/60 bg-emerald-500/10 text-emerald-100" + ? "border-emerald-500/60 bg-emerald-500/15 text-emerald-50" : flash.type === "error" - ? "border-rose-500/60 bg-rose-500/10 text-rose-100" - : "border-sky-500/60 bg-sky-500/10 text-sky-100"; + ? "border-rose-500/60 bg-rose-500/15 text-rose-50" + : "border-sky-500/60 bg-sky-500/15 text-sky-50"; return ( -
- {flash.message} - + {flash.message} + +
); }; @@ -157,14 +164,14 @@ const DesktopNav = ({ ); }; -const AdminLayoutFrame = ({ children, flash, onFlashClose }: { children: ReactNode; flash: FlashMessage | null; onFlashClose: () => void }) => { +const AdminLayoutFrame = ({ children }: { children: ReactNode }) => { const location = useLocation(); const currentPath = location.pathname; return (
-
+

MY Admin

Мониторинг и управление узлом

@@ -174,12 +181,11 @@ const AdminLayoutFrame = ({ children, flash, onFlashClose }: { children: ReactNo
-
+
- {children}
@@ -297,7 +303,6 @@ export const AdminShell = () => { {authState !== "authorized" ? (
- {
) : ( - +
@@ -339,6 +344,7 @@ export const AdminShell = () => { )} + ); }; diff --git a/src/pages/admin/sections/Blockchain.tsx b/src/pages/admin/sections/Blockchain.tsx index 3e00b63..34711b4 100644 --- a/src/pages/admin/sections/Blockchain.tsx +++ b/src/pages/admin/sections/Blockchain.tsx @@ -1,11 +1,11 @@ import { useAdminBlockchain } from "~/shared/services/admin"; -import { Section, Badge } from "../components"; +import { Section, Badge, CopyButton } from "../components"; import { useAdminContext } from "../context"; import { formatDate, formatUnknown, numberFormatter } from "../utils/format"; const MetricCard = ({ label, value }: { label: string; value: string }) => { return ( -
+

{label}

{value}

@@ -52,7 +52,7 @@ export const AdminBlockchainPage = () => { ))}
-
+
@@ -68,8 +68,18 @@ export const AdminBlockchainPage = () => { {recent.map((task) => ( - - + + - + ))}
{task.id}{task.destination || "—"} +
+ {task.id} + +
+
{task.destination || "—"} {formatUnknown(task.amount)} @@ -79,13 +89,83 @@ export const AdminBlockchainPage = () => { {task.epoch ?? "—"} · {task.seqno ?? "—"} {task.transaction_hash || "—"} + {task.transaction_hash ? ( +
+ {task.transaction_hash} + +
+ ) : ( + + )} +
{formatDate(task.updated)}
+
+ {recent.map((task) => ( +
+
+
+ {task.id} + +
+ + {task.status} + +
+
+
+ Назначение + {task.destination || "—"} +
+
+ Сумма + {formatUnknown(task.amount)} +
+
+ Epoch · Seqno + + {task.epoch ?? "—"} · {task.seqno ?? "—"} + +
+
+ Hash + {task.transaction_hash ? ( +
+ {task.transaction_hash} + +
+ ) : ( + + )} +
+
+ Обновлено + {formatDate(task.updated)} +
+
+
+ ))} +
); }; diff --git a/src/pages/admin/sections/Licenses.tsx b/src/pages/admin/sections/Licenses.tsx index 7a5110f..d60601d 100644 --- a/src/pages/admin/sections/Licenses.tsx +++ b/src/pages/admin/sections/Licenses.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { useAdminLicenses } from "~/shared/services/admin"; -import { Section, PaginationControls } from "../components"; +import { Section, PaginationControls, CopyButton, Badge } from "../components"; import { useAdminContext } from "../context"; import { formatDate, numberFormatter } from "../utils/format"; @@ -147,7 +147,7 @@ export const AdminLicensesPage = () => {
-
+
@@ -169,33 +169,78 @@ export const AdminLicensesPage = () => { items.map((license) => (
-
#{numberFormatter.format(license.id)}
+
+ #{numberFormatter.format(license.id)} + +
{license.type ?? "—"} · {license.status ?? "—"}
license_type: {license.license_type ?? "—"}
- {license.onchain_address ? ( - - {license.onchain_address} - - ) : ( -
- )} -
- Владелец: {license.owner_address ?? "—"} +
+ {license.onchain_address ? ( + + ) : ( +
+ )} +
+ + Владелец: {license.owner_address ?? "—"} + + {license.owner_address ? ( + + ) : null} +
{license.user ? ( -
- ID {numberFormatter.format(license.user.id)} · TG {numberFormatter.format(license.user.telegram_id)} +
+
+ ID {numberFormatter.format(license.user.id)} + +
+
+ TG {numberFormatter.format(license.user.telegram_id)} + +
) : (
@@ -205,8 +250,16 @@ export const AdminLicensesPage = () => { {license.content ? ( <>
{license.content.title}
-
- {license.content.hash} +
+ {license.content.hash} + {license.content.hash ? ( + + ) : null}
) : ( @@ -223,6 +276,106 @@ export const AdminLicensesPage = () => {
+
+ {items.length === 0 ? ( +
+ Ничего не найдено. +
+ ) : ( + items.map((license) => ( +
+
+
+ #{numberFormatter.format(license.id)} + +
+ + {license.status ?? "—"} + +
+
+ {license.type ?? "—"} · license_type: {license.license_type ?? "—"} +
+
+
+ On-chain адрес +
+ + {license.onchain_address ?? "—"} + + {license.onchain_address ? ( + + ) : null} +
+
+
+ Адрес владельца +
+ {license.owner_address ?? "—"} + {license.owner_address ? ( + + ) : null} +
+
+ {license.user ? ( +
+ ID {numberFormatter.format(license.user.id)} + + · TG {numberFormatter.format(license.user.telegram_id)} + +
+ ) : null} + {license.content ? ( +
+ Контент + {license.content.title} +
+ {license.content.hash} + {license.content.hash ? ( + + ) : null} +
+
+ ) : null} +
+
+
Создано: {formatDate(license.created_at)}
+
Обновлено: {formatDate(license.updated_at)}
+
+
+ )) + )} +
diff --git a/src/pages/admin/sections/Nodes.tsx b/src/pages/admin/sections/Nodes.tsx index 78af1e9..30cf2bf 100644 --- a/src/pages/admin/sections/Nodes.tsx +++ b/src/pages/admin/sections/Nodes.tsx @@ -1,7 +1,7 @@ import { ChangeEvent, useMemo } from "react"; import { useAdminNodeSetRole, useAdminNodes } from "~/shared/services/admin"; -import { Section } from "../components"; +import { Section, CopyButton } from "../components"; import { useAdminContext } from "../context"; import { formatDate, formatUnknown } from "../utils/format"; @@ -56,7 +56,7 @@ export const AdminNodesPage = () => { return (
-
+
@@ -72,9 +72,48 @@ export const AdminNodesPage = () => { {sortedItems.map((node, index) => ( - - - + + + - + ))}
{node.ip || "—"}{node.port ?? "—"}{node.public_key || "—"} + {node.ip ? ( +
+ {node.ip} + +
+ ) : ( + + )} +
+ {node.port != null ? ( +
+ {node.port} + +
+ ) : ( + + )} +
+ {node.public_key ? ( +
+ {node.public_key} + +
+ ) : ( + + )} +
{node.version || "—"} {formatDate(node.last_seen)}{formatUnknown(node.notes)}{formatUnknown(node.notes)}
+
+ {sortedItems.map((node, index) => ( +
+
+
+ IP + {node.ip ?? "—"} + {node.ip ? : null} +
+ + {node.role} + +
+
+
+ Порт + {node.port ?? "—"} + {node.port != null ? ( + + ) : null} +
+
+ Публичный ключ +
+ {node.public_key ?? "—"} + {node.public_key ? ( + + ) : null} +
+
+
+ Версия + {node.version || "—"} +
+
+ Последний онлайн + {formatDate(node.last_seen)} +
+
+ Заметки + {formatUnknown(node.notes)} +
+
+
+ + +
+
+ ))} +
); }; diff --git a/src/pages/admin/sections/Overview.tsx b/src/pages/admin/sections/Overview.tsx index f96af99..d08a9e0 100644 --- a/src/pages/admin/sections/Overview.tsx +++ b/src/pages/admin/sections/Overview.tsx @@ -1,7 +1,7 @@ import { useMemo } from "react"; import { useAdminOverview } from "~/shared/services/admin"; -import { Section, Badge, InfoRow } from "../components"; +import { Section, Badge, InfoRow, CopyButton } from "../components"; import { useAdminContext } from "../context"; import { formatBytes, formatDate, formatUnknown, numberFormatter } from "../utils/format"; @@ -28,16 +28,22 @@ export const AdminOverviewPage = () => { label: "Хост", value: project.host || "локальный", helper: project.name, + copyValue: project.host ?? null, + successMessage: "Хост скопирован", }, { label: "TON Master", value: node.ton_master, helper: "Платформа", + copyValue: node.ton_master ?? null, + successMessage: "TON Master скопирован", }, { label: "Service Wallet", value: node.service_wallet, helper: node.id, + copyValue: node.service_wallet ?? null, + successMessage: "Service wallet скопирован", }, { label: "Контент", @@ -58,6 +64,8 @@ export const AdminOverviewPage = () => { label: "Билд", value: codebase.commit ?? "n/a", helper: codebase.branch ?? "", + copyValue: codebase.commit ?? null, + successMessage: "Хеш коммита скопирован", }, { label: "Python", @@ -76,6 +84,11 @@ export const AdminOverviewPage = () => { } const { project, services, ton, runtime, ipfs } = overviewQuery.data; + const ipfsIdentity = (ipfs.identity ?? {}) as Record; + const ipfsBitswap = (ipfs.bitswap ?? {}) as Record; + const ipfsRepo = (ipfs.repo ?? {}) as Record; + const ipfsId = ipfsIdentity["ID"]; + const ipfsAgent = ipfsIdentity["AgentVersion"]; return (
{ >
{overviewCards.map((card) => ( -
-

{card.label}

+
+
+

{card.label}

+ {card.copyValue ? ( + + ) : null} +

{card.value}

- {card.helper ?

{card.helper}

: null} + {card.helper ?

{card.helper}

: null}
))}
@@ -107,12 +132,16 @@ export const AdminOverviewPage = () => {

Проект

- {project.host || "—"} + + {project.host || "—"} + {project.name} {project.privacy} {ton.testnet ? "Да" : "Нет"} {ton.api_key_configured ? "Настроен" : "Нет"} - {ton.host || "—"} + + {ton.host || "—"} +
@@ -129,12 +158,20 @@ export const AdminOverviewPage = () => {

IPFS

-
- {formatUnknown((ipfs.identity as Record)?.ID)} - {formatUnknown((ipfs.identity as Record)?.AgentVersion)} - {numberFormatter.format(Number((ipfs.bitswap as Record)?.Peers ?? 0))} - {formatBytes(Number((ipfs.repo as Record)?.RepoSize ?? 0))} - {formatBytes(Number((ipfs.repo as Record)?.StorageMax ?? 0))} +
+ + {formatUnknown(ipfsId)} + + {formatUnknown(ipfsAgent)} + + {numberFormatter.format(Number(ipfsBitswap?.Peers ?? 0))} + + + {formatBytes(Number(ipfsRepo?.RepoSize ?? 0))} + + + {formatBytes(Number(ipfsRepo?.StorageMax ?? 0))} +
@@ -155,7 +192,7 @@ export const AdminOverviewPage = () => { key={service.name} className="flex items-center justify-between rounded-lg bg-slate-950/40 px-3 py-2 ring-1 ring-slate-800" > - {service.name} + {service.name}
{service.last_reported_seconds != null diff --git a/src/pages/admin/sections/Status.tsx b/src/pages/admin/sections/Status.tsx index 38cee12..e16823b 100644 --- a/src/pages/admin/sections/Status.tsx +++ b/src/pages/admin/sections/Status.tsx @@ -110,7 +110,7 @@ export const AdminStatusPage = () => {
-
+

IPFS

@@ -127,7 +127,7 @@ export const AdminStatusPage = () => {
-
+

Pin-статистика

{Object.entries(pin_counts).map(([key, value]) => ( @@ -142,7 +142,7 @@ export const AdminStatusPage = () => {
{ cacheLimitsMutation.mutate(values); })} @@ -177,7 +177,7 @@ export const AdminStatusPage = () => {
{ cacheCleanupMutation.mutate({ mode: "fit", max_gb: values.max_gb }); })} @@ -212,7 +212,7 @@ export const AdminStatusPage = () => {
{ syncLimitsMutation.mutate(values); })} diff --git a/src/pages/admin/sections/Storage.tsx b/src/pages/admin/sections/Storage.tsx index 8937852..697fe30 100644 --- a/src/pages/admin/sections/Storage.tsx +++ b/src/pages/admin/sections/Storage.tsx @@ -1,5 +1,5 @@ import { useAdminStorage } from "~/shared/services/admin"; -import { Section, Badge, InfoRow } from "../components"; +import { Section, Badge, InfoRow, CopyButton } from "../components"; import { useAdminContext } from "../context"; import { formatBytes, numberFormatter } from "../utils/format"; @@ -30,7 +30,7 @@ export const AdminStoragePage = () => { return (
-
+
@@ -43,7 +43,17 @@ export const AdminStoragePage = () => { {directories.map((dir) => ( - +
{dir.path} +
+ {dir.path} + +
+
{numberFormatter.format(dir.file_count)} {formatBytes(dir.size_bytes)} @@ -54,11 +64,41 @@ export const AdminStoragePage = () => {
+
+ {directories.map((dir) => ( +
+
+
+ {dir.path} + +
+ {dir.exists ? "OK" : "Нет"} +
+
+
+ Файлов +
{numberFormatter.format(dir.file_count)}
+
+
+ Размер +
{formatBytes(dir.size_bytes)}
+
+
+
+ ))} +
{disk ? (

Снимок диска

- {disk.path} + + {disk.path} + {formatBytes(disk.free_bytes)} {formatBytes(disk.used_bytes)} {formatBytes(disk.total_bytes)} diff --git a/src/pages/admin/sections/Uploads.tsx b/src/pages/admin/sections/Uploads.tsx index ac9dbf1..25a2722 100644 --- a/src/pages/admin/sections/Uploads.tsx +++ b/src/pages/admin/sections/Uploads.tsx @@ -6,7 +6,7 @@ import { type AdminUploadsContent, type AdminUploadsContentFlags, } from "~/shared/services/admin"; -import { Section, Badge, InfoRow } from "../components"; +import { Section, Badge, InfoRow, CopyButton } from "../components"; import { useAdminContext } from "../context"; import { formatBytes, formatDate, numberFormatter } from "../utils/format"; @@ -168,7 +168,7 @@ const UploadCard = ({ decoration }: { decoration: UploadDecoration }) => { const derivativeDownloads = item.links.download_derivatives ?? []; return ( -
+

{item.title || "Без названия"}

@@ -192,13 +192,13 @@ const UploadCard = ({ decoration }: { decoration: UploadDecoration }) => {

On-chain

- + {item.encrypted_cid} - + {item.metadata_cid ?? "—"} - + {item.content_hash ?? "—"} @@ -211,7 +211,7 @@ const UploadCard = ({ decoration }: { decoration: UploadDecoration }) => { не опубликовано )} - + {item.status.onchain?.item_address ?? "—"}
@@ -316,11 +316,48 @@ const UploadCard = ({ decoration }: { decoration: UploadDecoration }) => {

Хранение

{item.stored ? (
-
ID: {item.stored.stored_id ?? "—"}
-
Владелец: {item.stored.owner_address ?? "—"}
+
+ ID: + {item.stored.stored_id ?? "—"} + {item.stored.stored_id ? ( + + ) : null} +
+
+ Владелец: + {item.stored.owner_address ?? "—"} + {item.stored.owner_address ? ( + + ) : null} +
Тип: {item.stored.type ?? "—"}
{item.stored.user ? ( -
Пользователь #{item.stored.user.id} · TG {item.stored.user.telegram_id}
+
+ Пользователь #{item.stored.user.id} + + · TG {item.stored.user.telegram_id} + +
) : null} {item.stored.download_url ? ( { >
{Object.entries(states ?? {}).map(([key, value]) => ( -
+

{key}

{numberFormatter.format(value)}

))} -
+

Всего

{numberFormatter.format(total)}

Отфильтровано: {numberFormatter.format(filteredContents.length)}

-
+

Активные задачи

{numberCompact.format(categoryCounts.processing)}

{numberFormatter.format(categoryCounts.issues)} с ошибками

diff --git a/src/pages/admin/sections/Users.tsx b/src/pages/admin/sections/Users.tsx index 357bd10..cc9fb5e 100644 --- a/src/pages/admin/sections/Users.tsx +++ b/src/pages/admin/sections/Users.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; import { useAdminUsers } from "~/shared/services/admin"; -import { Section, PaginationControls } from "../components"; +import { Section, PaginationControls, CopyButton } from "../components"; import { useAdminContext } from "../context"; import { formatDate, formatStars, numberFormatter } from "../utils/format"; @@ -88,7 +88,10 @@ export const AdminUsersPage = () => { >
{summaryCards.map((card) => ( -
+

{card.label}

{card.value}

{card.helper ?

{card.helper}

: null} @@ -117,7 +120,7 @@ export const AdminUsersPage = () => {
-
+
@@ -141,8 +144,21 @@ export const AdminUsersPage = () => {
{user.username ? `@${user.username}` : "Без username"}
-
- ID {numberFormatter.format(user.id)} · TG {numberFormatter.format(user.telegram_id)} +
+ ID {numberFormatter.format(user.id)} + + · TG {numberFormatter.format(user.telegram_id)} +
{user.first_name || user.last_name ? (
@@ -155,7 +171,17 @@ export const AdminUsersPage = () => { Активных {numberFormatter.format(user.wallets.active_count)} / всего{" "} {numberFormatter.format(user.wallets.total_count)}
-
{user.wallets.primary_address ?? "—"}
+
+ {user.wallets.primary_address ?? "—"} + {user.wallets.primary_address ? ( + + ) : null} +
{formatStars(user.stars.amount_total)}
@@ -168,8 +194,19 @@ export const AdminUsersPage = () => {
{user.ip_activity.last ? ( -
- {user.ip_activity.last.ip ?? "—"} · {formatDate(user.ip_activity.last.seen_at)} +
+
+ {user.ip_activity.last.ip ?? "—"} + {user.ip_activity.last.ip ? ( + + ) : null} +
+ {formatDate(user.ip_activity.last.seen_at)}
) : (
Нет данных
@@ -185,6 +222,105 @@ export const AdminUsersPage = () => {
+
+ {items.length === 0 ? ( +
+ Нет пользователей, удовлетворяющих условиям. +
+ ) : ( + items.map((user) => ( +
+
+ {user.username ? `@${user.username}` : "Без username"} + + +
+ {user.first_name || user.last_name ? ( +
+ {(user.first_name ?? "")} {(user.last_name ?? "")} +
+ ) : null} +
+
+ ID · Telegram +
+ ID {numberFormatter.format(user.id)} + · TG {numberFormatter.format(user.telegram_id)} +
+
+
+ Кошельки +
+ + Активных {numberFormatter.format(user.wallets.active_count)} / всего{" "} + {numberFormatter.format(user.wallets.total_count)} + + {user.wallets.primary_address ? ( + + ) : null} +
+ {user.wallets.primary_address ?? "—"} +
+
+ Stars +
+ {formatStars(user.stars.amount_total)} + Оплачено: {formatStars(user.stars.amount_paid)} + Неоплачено: {formatStars(user.stars.amount_unpaid)} +
+
+
+ Лицензии + + Всего {numberFormatter.format(user.licenses.total)} · Активных{" "} + {numberFormatter.format(user.licenses.active)} + +
+
+ Последняя активность + {user.ip_activity.last ? ( +
+
+ {user.ip_activity.last.ip ?? "—"} + {user.ip_activity.last.ip ? ( + + ) : null} +
+ {formatDate(user.ip_activity.last.seen_at)} +
+ ) : ( + Нет данных + )} +
+
+
+
Создан: {formatDate(user.created_at)}
+
Последний вход: {formatDate(user.last_use)}
+
+
+ )) + )} +