admin improve

This commit is contained in:
root 2025-10-11 21:42:47 +00:00
parent 82f205bf5c
commit 33e0349a1b
13 changed files with 805 additions and 99 deletions

View File

@ -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 (
<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",
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>
</button>
);
};

View File

@ -1,10 +1,38 @@
import type { ReactNode } from "react"; 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 ( return (
<div className="flex flex-col gap-1 rounded-lg border border-slate-800 bg-slate-950/60 p-3"> <div
className={clsx(
"flex flex-col gap-1 overflow-hidden rounded-lg border border-slate-800 bg-slate-950/60 p-3",
className,
)}
>
<span className="text-xs uppercase tracking-wide text-slate-500">{label}</span> <span className="text-xs uppercase tracking-wide text-slate-500">{label}</span>
<span className="text-sm text-slate-100">{children}</span> <div className="flex items-start gap-2 text-sm text-slate-100">
<div className="min-w-0 break-words text-left">{children}</div>
{canCopy ? (
<CopyButton
value={copyValue as string | number}
aria-label={`Скопировать значение «${label}»`}
successMessage={copyMessage}
className="mt-0.5"
/>
) : null}
</div>
</div> </div>
); );
}; };

View File

@ -15,16 +15,16 @@ export const Section = ({ id, title, description, actions, children, className }
<section <section
id={id} id={id}
className={clsx( className={clsx(
"rounded-2xl bg-slate-900/60 p-6 shadow-inner shadow-black/40 ring-1 ring-slate-800", "relative w-full overflow-hidden rounded-2xl bg-slate-900/60 p-6 shadow-inner shadow-black/40 ring-1 ring-slate-800",
className, className,
)} )}
> >
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between"> <div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div> <div className="min-w-0">
<h2 className="text-2xl font-semibold text-slate-100">{title}</h2> <h2 className="text-2xl font-semibold text-slate-100">{title}</h2>
{description ? <p className="mt-1 max-w-3xl text-sm text-slate-400">{description}</p> : null} {description ? <p className="mt-1 max-w-3xl break-words text-sm text-slate-400">{description}</p> : null}
</div> </div>
{actions ? <div className="flex h-full items-center gap-3">{actions}</div> : null} {actions ? <div className="flex h-full shrink-0 flex-wrap items-center gap-3">{actions}</div> : null}
</div> </div>
<div className="space-y-6 text-sm text-slate-200">{children}</div> <div className="space-y-6 text-sm text-slate-200">{children}</div>
</section> </section>

View File

@ -2,3 +2,4 @@ export * from "./Section";
export * from "./Badge"; export * from "./Badge";
export * from "./PaginationControls"; export * from "./PaginationControls";
export * from "./InfoRow"; export * from "./InfoRow";
export * from "./CopyButton";

View File

@ -30,20 +30,27 @@ const AdminFlash = ({ flash, onClose }: { flash: FlashMessage | null; onClose: (
} }
const tone = const tone =
flash.type === "success" 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" : flash.type === "error"
? "border-rose-500/60 bg-rose-500/10 text-rose-100" ? "border-rose-500/60 bg-rose-500/15 text-rose-50"
: "border-sky-500/60 bg-sky-500/10 text-sky-100"; : "border-sky-500/60 bg-sky-500/15 text-sky-50";
return ( return (
<div className={clsx("mb-6 flex items-start justify-between gap-4 rounded-lg border px-4 py-3 text-sm", tone)}> <div className="pointer-events-none fixed inset-x-4 top-4 z-50 flex justify-center sm:inset-x-auto sm:left-auto sm:right-6">
<span>{flash.message}</span> <div
<button className={clsx(
type="button" "pointer-events-auto flex w-full max-w-md items-start gap-4 rounded-2xl border px-5 py-4 text-sm shadow-2xl shadow-black/40 backdrop-blur-md",
className="text-xs font-semibold uppercase tracking-wide text-slate-300 transition hover:text-white" tone,
onClick={onClose} )}
> >
Закрыть <span className="flex-1 break-words">{flash.message}</span>
</button> <button
type="button"
className="text-xs font-semibold uppercase tracking-wide text-slate-200 transition hover:text-white"
onClick={onClose}
>
Закрыть
</button>
</div>
</div> </div>
); );
}; };
@ -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 location = useLocation();
const currentPath = location.pathname; const currentPath = location.pathname;
return ( return (
<div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 text-slate-100"> <div className="min-h-screen bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 text-slate-100">
<header className="border-b border-slate-800 bg-slate-950/70 backdrop-blur"> <header className="border-b border-slate-800 bg-slate-950/70 backdrop-blur">
<div className="mx-auto flex max-w-7xl items-center justify-between gap-4 px-6 py-4"> <div className="flex w-full items-center justify-between gap-4 px-4 py-4 sm:px-6 lg:px-8">
<div> <div>
<h1 className="text-xl font-semibold text-white">MY Admin</h1> <h1 className="text-xl font-semibold text-white">MY Admin</h1>
<p className="text-xs text-slate-500">Мониторинг и управление узлом</p> <p className="text-xs text-slate-500">Мониторинг и управление узлом</p>
@ -174,12 +181,11 @@ const AdminLayoutFrame = ({ children, flash, onFlashClose }: { children: ReactNo
</div> </div>
</div> </div>
</header> </header>
<main className="mx-auto flex w-full max-w-7xl flex-1 flex-col gap-6 px-3 py-6 md:flex-row md:px-6"> <main className="flex w-full flex-1 flex-col gap-6 px-4 py-6 sm:px-6 lg:px-8 md:flex-row">
<DesktopNav sections={ADMIN_SECTIONS} currentPath={currentPath} /> <DesktopNav sections={ADMIN_SECTIONS} currentPath={currentPath} />
<div className="w-full max-w-full md:flex-1"> <div className="w-full max-w-full md:flex-1">
<MobileNav sections={ADMIN_SECTIONS} currentPath={currentPath} /> <MobileNav sections={ADMIN_SECTIONS} currentPath={currentPath} />
<div className="mt-4 space-y-6 md:mt-0"> <div className="mt-4 space-y-6 md:mt-0">
<AdminFlash flash={flash} onClose={onFlashClose} />
{children} {children}
</div> </div>
</div> </div>
@ -297,7 +303,6 @@ export const AdminShell = () => {
{authState !== "authorized" ? ( {authState !== "authorized" ? (
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 px-6 py-10"> <div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-slate-950 via-slate-900 to-slate-950 px-6 py-10">
<div className="w-full max-w-xl space-y-6"> <div className="w-full max-w-xl space-y-6">
<AdminFlash flash={flash} onClose={clearFlash} />
<AdminLoginForm <AdminLoginForm
token={token} token={token}
onTokenChange={setToken} onTokenChange={setToken}
@ -313,7 +318,7 @@ export const AdminShell = () => {
</div> </div>
</div> </div>
) : ( ) : (
<AdminLayoutFrame flash={flash} onFlashClose={clearFlash}> <AdminLayoutFrame>
<div className="flex items-center justify-between gap-3"> <div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="rounded-full bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-200 ring-1 ring-emerald-500/40"> <span className="rounded-full bg-emerald-500/10 px-3 py-1 text-xs font-semibold text-emerald-200 ring-1 ring-emerald-500/40">
@ -339,6 +344,7 @@ export const AdminShell = () => {
<Outlet /> <Outlet />
</AdminLayoutFrame> </AdminLayoutFrame>
)} )}
<AdminFlash flash={flash} onClose={clearFlash} />
</AdminContext.Provider> </AdminContext.Provider>
); );
}; };

View File

@ -1,11 +1,11 @@
import { useAdminBlockchain } from "~/shared/services/admin"; import { useAdminBlockchain } from "~/shared/services/admin";
import { Section, Badge } from "../components"; import { Section, 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";
const MetricCard = ({ label, value }: { label: string; value: string }) => { const MetricCard = ({ label, value }: { label: string; value: string }) => {
return ( return (
<div className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800"> <div className="overflow-hidden rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
<p className="text-xs uppercase tracking-wide text-slate-500">{label}</p> <p className="text-xs uppercase tracking-wide text-slate-500">{label}</p>
<p className="mt-2 text-lg font-semibold text-slate-100">{value}</p> <p className="mt-2 text-lg font-semibold text-slate-100">{value}</p>
</div> </div>
@ -52,7 +52,7 @@ export const AdminBlockchainPage = () => {
<MetricCard key={key} label={key} value={numberFormatter.format(value)} /> <MetricCard key={key} label={key} value={numberFormatter.format(value)} />
))} ))}
</div> </div>
<div className="overflow-x-auto"> <div className="hidden overflow-x-auto rounded-2xl border border-slate-800 md:block">
<table className="min-w-full divide-y divide-slate-800 text-left text-sm"> <table className="min-w-full divide-y divide-slate-800 text-left text-sm">
<thead className="bg-slate-950/70 text-xs uppercase tracking-wide text-slate-400"> <thead className="bg-slate-950/70 text-xs uppercase tracking-wide text-slate-400">
<tr> <tr>
@ -68,8 +68,18 @@ export const AdminBlockchainPage = () => {
<tbody className="divide-y divide-slate-900/60"> <tbody className="divide-y divide-slate-900/60">
{recent.map((task) => ( {recent.map((task) => (
<tr key={task.id} className="hover:bg-slate-900/50"> <tr key={task.id} className="hover:bg-slate-900/50">
<td className="px-3 py-2 font-mono text-xs text-slate-300">{task.id}</td> <td className="px-3 py-2">
<td className="px-3 py-2">{task.destination || "—"}</td> <div className="flex items-center gap-2">
<span className="font-mono text-xs text-slate-300">{task.id}</span>
<CopyButton
value={task.id}
aria-label="Скопировать ID задачи"
successMessage="ID задачи скопирован"
className="h-6 w-6"
/>
</div>
</td>
<td className="px-3 py-2 break-words">{task.destination || "—"}</td>
<td className="px-3 py-2">{formatUnknown(task.amount)}</td> <td className="px-3 py-2">{formatUnknown(task.amount)}</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<Badge tone={task.status === "done" ? "success" : task.status === "failed" ? "danger" : "neutral"}> <Badge tone={task.status === "done" ? "success" : task.status === "failed" ? "danger" : "neutral"}>
@ -79,13 +89,83 @@ export const AdminBlockchainPage = () => {
<td className="px-3 py-2"> <td className="px-3 py-2">
{task.epoch ?? "—"} · {task.seqno ?? "—"} {task.epoch ?? "—"} · {task.seqno ?? "—"}
</td> </td>
<td className="px-3 py-2 font-mono text-xs text-slate-500">{task.transaction_hash || "—"}</td> <td className="px-3 py-2">
{task.transaction_hash ? (
<div className="flex items-center gap-2">
<span className="break-all font-mono text-xs text-slate-500">{task.transaction_hash}</span>
<CopyButton
value={task.transaction_hash}
aria-label="Скопировать хеш транзакции"
successMessage="Хеш транзакции скопирован"
className="h-6 w-6"
/>
</div>
) : (
<span className="text-xs text-slate-500"></span>
)}
</td>
<td className="px-3 py-2">{formatDate(task.updated)}</td> <td className="px-3 py-2">{formatDate(task.updated)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="grid gap-3 md:hidden">
{recent.map((task) => (
<div key={`task-card-${task.id}`} className="rounded-2xl border border-slate-800 bg-slate-950/40 p-4 shadow-inner shadow-black/30">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-slate-300">{task.id}</span>
<CopyButton
value={task.id}
aria-label="Скопировать ID задачи"
successMessage="ID задачи скопирован"
className="h-6 w-6"
/>
</div>
<Badge tone={task.status === "done" ? "success" : task.status === "failed" ? "danger" : "neutral"}>
{task.status}
</Badge>
</div>
<div className="mt-3 grid gap-2 text-xs text-slate-300">
<div className="flex flex-col gap-1">
<span className="text-slate-500">Назначение</span>
<span className="break-words">{task.destination || "—"}</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-slate-500">Сумма</span>
<span>{formatUnknown(task.amount)}</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-slate-500">Epoch · Seqno</span>
<span>
{task.epoch ?? "—"} · {task.seqno ?? "—"}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-slate-500">Hash</span>
{task.transaction_hash ? (
<div className="flex items-center gap-2">
<span className="break-all font-mono text-[11px] text-slate-500">{task.transaction_hash}</span>
<CopyButton
value={task.transaction_hash}
aria-label="Скопировать хеш транзакции"
successMessage="Хеш транзакции скопирован"
className="h-6 w-6"
/>
</div>
) : (
<span className="text-slate-500"></span>
)}
</div>
<div className="flex flex-col gap-1">
<span className="text-slate-500">Обновлено</span>
<span>{formatDate(task.updated)}</span>
</div>
</div>
</div>
))}
</div>
</Section> </Section>
); );
}; };

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useAdminLicenses } from "~/shared/services/admin"; import { useAdminLicenses } from "~/shared/services/admin";
import { Section, PaginationControls } from "../components"; import { Section, PaginationControls, CopyButton, Badge } from "../components";
import { useAdminContext } from "../context"; import { useAdminContext } from "../context";
import { formatDate, numberFormatter } from "../utils/format"; import { formatDate, numberFormatter } from "../utils/format";
@ -147,7 +147,7 @@ export const AdminLicensesPage = () => {
</div> </div>
</div> </div>
<div className="mt-4 overflow-x-auto"> <div className="mt-4 hidden overflow-x-auto rounded-2xl border border-slate-800 md:block">
<table className="min-w-full divide-y divide-slate-800 text-left text-sm"> <table className="min-w-full divide-y divide-slate-800 text-left text-sm">
<thead className="bg-slate-950/70 text-xs uppercase tracking-wide text-slate-400"> <thead className="bg-slate-950/70 text-xs uppercase tracking-wide text-slate-400">
<tr> <tr>
@ -169,33 +169,78 @@ export const AdminLicensesPage = () => {
items.map((license) => ( items.map((license) => (
<tr key={`license-${license.id}`} className="hover:bg-slate-900/40"> <tr key={`license-${license.id}`} className="hover:bg-slate-900/40">
<td className="px-3 py-3 align-top"> <td className="px-3 py-3 align-top">
<div className="font-semibold text-slate-100">#{numberFormatter.format(license.id)}</div> <div className="flex flex-wrap items-center gap-2 font-semibold text-slate-100">
<span>#{numberFormatter.format(license.id)}</span>
<CopyButton
value={license.id}
aria-label="Скопировать ID лицензии"
successMessage="ID лицензии скопирован"
className="h-6 w-6"
/>
</div>
<div className="text-xs text-slate-400">{license.type ?? "—"} · {license.status ?? "—"}</div> <div className="text-xs text-slate-400">{license.type ?? "—"} · {license.status ?? "—"}</div>
<div className="text-[10px] uppercase tracking-wide text-slate-500"> <div className="text-[10px] uppercase tracking-wide text-slate-500">
license_type: {license.license_type ?? "—"} license_type: {license.license_type ?? "—"}
</div> </div>
</td> </td>
<td className="px-3 py-3 align-top"> <td className="px-3 py-3 align-top">
{license.onchain_address ? ( <div className="flex flex-col gap-1">
<a {license.onchain_address ? (
href={`https://tonviewer.com/${license.onchain_address}`} <div className="flex items-center gap-2 text-xs">
target="_blank" <a
rel="noreferrer" href={`https://tonviewer.com/${license.onchain_address}`}
className="break-all text-xs text-sky-300 hover:text-sky-200" target="_blank"
> rel="noreferrer"
{license.onchain_address} className="break-all text-sky-300 hover:text-sky-200"
</a> >
) : ( {license.onchain_address}
<div className="text-xs text-slate-500"></div> </a>
)} <CopyButton
<div className="text-[10px] uppercase tracking-wide text-slate-500"> value={license.onchain_address}
Владелец: {license.owner_address ?? "—"} aria-label="Скопировать on-chain адрес"
successMessage="On-chain адрес скопирован"
className="h-6 w-6"
/>
</div>
) : (
<div className="text-xs text-slate-500"></div>
)}
<div className="flex items-center gap-2 text-[10px] uppercase tracking-wide text-slate-500">
<span className="break-all">
Владелец: {license.owner_address ?? "—"}
</span>
{license.owner_address ? (
<CopyButton
value={license.owner_address}
aria-label="Скопировать адрес владельца"
successMessage="Адрес владельца скопирован"
className="h-6 w-6"
/>
) : null}
</div>
</div> </div>
</td> </td>
<td className="px-3 py-3 align-top"> <td className="px-3 py-3 align-top">
{license.user ? ( {license.user ? (
<div className="text-xs text-slate-300"> <div className="flex flex-col gap-1 text-xs text-slate-300">
ID {numberFormatter.format(license.user.id)} · TG {numberFormatter.format(license.user.telegram_id)} <div className="flex flex-wrap items-center gap-2">
<span>ID {numberFormatter.format(license.user.id)}</span>
<CopyButton
value={license.user.id}
aria-label="Скопировать ID пользователя"
successMessage="ID пользователя скопирован"
className="h-6 w-6"
/>
</div>
<div className="flex flex-wrap items-center gap-2">
<span>TG {numberFormatter.format(license.user.telegram_id)}</span>
<CopyButton
value={license.user.telegram_id}
aria-label="Скопировать Telegram ID"
successMessage="Telegram ID скопирован"
className="h-6 w-6"
/>
</div>
</div> </div>
) : ( ) : (
<div className="text-xs text-slate-500"></div> <div className="text-xs text-slate-500"></div>
@ -205,8 +250,16 @@ export const AdminLicensesPage = () => {
{license.content ? ( {license.content ? (
<> <>
<div className="text-xs text-slate-300">{license.content.title}</div> <div className="text-xs text-slate-300">{license.content.title}</div>
<div className="break-all text-[10px] uppercase tracking-wide text-slate-500"> <div className="flex items-center gap-2 text-[10px] uppercase tracking-wide text-slate-500">
{license.content.hash} <span className="break-all">{license.content.hash}</span>
{license.content.hash ? (
<CopyButton
value={license.content.hash}
aria-label="Скопировать хеш контента"
successMessage="Хеш контента скопирован"
className="h-6 w-6"
/>
) : null}
</div> </div>
</> </>
) : ( ) : (
@ -223,6 +276,106 @@ export const AdminLicensesPage = () => {
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="mt-4 grid gap-4 md:hidden">
{items.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>
) : (
items.map((license) => (
<div key={`license-card-${license.id}`} className="rounded-2xl border border-slate-800 bg-slate-950/40 p-4 shadow-inner shadow-black/30">
<div className="flex items-start justify-between gap-2">
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold text-slate-100">
<span>#{numberFormatter.format(license.id)}</span>
<CopyButton
value={license.id}
aria-label="Скопировать ID лицензии"
successMessage="ID лицензии скопирован"
className="h-6 w-6"
/>
</div>
<Badge tone={license.status === "active" ? "success" : "neutral"}>
{license.status ?? "—"}
</Badge>
</div>
<div className="mt-2 text-xs text-slate-400">
<span>{license.type ?? "—"}</span> · <span>license_type: {license.license_type ?? "—"}</span>
</div>
<div className="mt-3 space-y-3 text-xs text-slate-300">
<div className="flex flex-col gap-1">
<span className="text-slate-500">On-chain адрес</span>
<div className="flex items-center gap-2">
<span className="break-all text-sky-300">
{license.onchain_address ?? "—"}
</span>
{license.onchain_address ? (
<CopyButton
value={license.onchain_address}
aria-label="Скопировать on-chain адрес"
successMessage="On-chain адрес скопирован"
className="h-6 w-6"
/>
) : null}
</div>
</div>
<div className="flex flex-col gap-1">
<span className="text-slate-500">Адрес владельца</span>
<div className="flex items-center gap-2 text-[11px] text-slate-400">
<span className="break-all">{license.owner_address ?? "—"}</span>
{license.owner_address ? (
<CopyButton
value={license.owner_address}
aria-label="Скопировать адрес владельца"
successMessage="Адрес владельца скопирован"
className="h-6 w-6"
/>
) : null}
</div>
</div>
{license.user ? (
<div className="flex flex-wrap items-center gap-2 text-slate-300">
<span>ID {numberFormatter.format(license.user.id)}</span>
<CopyButton
value={license.user.id}
aria-label="Скопировать ID пользователя"
successMessage="ID пользователя скопирован"
className="h-6 w-6"
/>
<span>· TG {numberFormatter.format(license.user.telegram_id)}</span>
<CopyButton
value={license.user.telegram_id}
aria-label="Скопировать Telegram ID"
successMessage="Telegram ID скопирован"
className="h-6 w-6"
/>
</div>
) : null}
{license.content ? (
<div className="flex flex-col gap-1">
<span className="text-slate-500">Контент</span>
<span>{license.content.title}</span>
<div className="flex items-center gap-2 text-[10px] uppercase tracking-wide text-slate-500">
<span className="break-all">{license.content.hash}</span>
{license.content.hash ? (
<CopyButton
value={license.content.hash}
aria-label="Скопировать хеш контента"
successMessage="Хеш контента скопирован"
className="h-6 w-6"
/>
) : null}
</div>
</div>
) : null}
</div>
<div className="mt-3 text-[11px] text-slate-500">
<div>Создано: {formatDate(license.created_at)}</div>
<div>Обновлено: {formatDate(license.updated_at)}</div>
</div>
</div>
))
)}
</div>
<div className="mt-4"> <div className="mt-4">
<PaginationControls total={total} limit={limit} page={page} onPageChange={setPage} /> <PaginationControls total={total} limit={limit} page={page} onPageChange={setPage} />

View File

@ -1,7 +1,7 @@
import { ChangeEvent, useMemo } from "react"; import { ChangeEvent, useMemo } from "react";
import { useAdminNodeSetRole, useAdminNodes } from "~/shared/services/admin"; import { useAdminNodeSetRole, useAdminNodes } from "~/shared/services/admin";
import { Section } from "../components"; import { Section, CopyButton } from "../components";
import { useAdminContext } from "../context"; import { useAdminContext } from "../context";
import { formatDate, formatUnknown } from "../utils/format"; import { formatDate, formatUnknown } from "../utils/format";
@ -56,7 +56,7 @@ export const AdminNodesPage = () => {
return ( return (
<Section id="nodes" title="Ноды" description="Роли, версии и последнее появление"> <Section id="nodes" title="Ноды" description="Роли, версии и последнее появление">
<div className="overflow-x-auto"> <div className="hidden overflow-x-auto rounded-2xl border border-slate-800 md:block">
<table className="min-w-full divide-y divide-slate-800 text-left text-sm"> <table className="min-w-full divide-y divide-slate-800 text-left text-sm">
<thead className="bg-slate-950/70 text-xs uppercase tracking-wide text-slate-400"> <thead className="bg-slate-950/70 text-xs uppercase tracking-wide text-slate-400">
<tr> <tr>
@ -72,9 +72,48 @@ export const AdminNodesPage = () => {
<tbody className="divide-y divide-slate-900/60"> <tbody className="divide-y divide-slate-900/60">
{sortedItems.map((node, index) => ( {sortedItems.map((node, index) => (
<tr key={node.public_key ?? node.ip ?? `node-${index}`} className="hover:bg-slate-900/50"> <tr key={node.public_key ?? node.ip ?? `node-${index}`} className="hover:bg-slate-900/50">
<td className="px-3 py-2">{node.ip || "—"}</td> <td className="px-3 py-2">
<td className="px-3 py-2">{node.port ?? "—"}</td> {node.ip ? (
<td className="px-3 py-2 font-mono text-xs text-slate-300">{node.public_key || "—"}</td> <div className="flex items-center gap-2 text-sm text-slate-200">
<span className="break-all font-mono text-xs text-slate-300">{node.ip}</span>
<CopyButton
value={node.ip}
aria-label="Скопировать IP-адрес"
successMessage="IP-адрес узла скопирован"
/>
</div>
) : (
<span className="text-xs text-slate-500"></span>
)}
</td>
<td className="px-3 py-2">
{node.port != null ? (
<div className="flex items-center gap-2 text-sm text-slate-200">
<span className="font-mono text-xs text-slate-300">{node.port}</span>
<CopyButton
value={node.port}
aria-label="Скопировать порт"
successMessage="Порт узла скопирован"
/>
</div>
) : (
<span className="text-xs text-slate-500"></span>
)}
</td>
<td className="px-3 py-2">
{node.public_key ? (
<div className="flex items-center gap-2">
<span className="break-all font-mono text-[11px] text-slate-300">{node.public_key}</span>
<CopyButton
value={node.public_key}
aria-label="Скопировать публичный ключ"
successMessage="Публичный ключ узла скопирован"
/>
</div>
) : (
<span className="text-xs text-slate-500"></span>
)}
</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<select <select
className="rounded-lg border border-slate-700 bg-slate-950 px-2 py-1 text-xs text-slate-100 focus:border-sky-500 focus:outline-none" className="rounded-lg border border-slate-700 bg-slate-950 px-2 py-1 text-xs text-slate-100 focus:border-sky-500 focus:outline-none"
@ -89,12 +128,78 @@ export const AdminNodesPage = () => {
</td> </td>
<td className="px-3 py-2">{node.version || "—"}</td> <td className="px-3 py-2">{node.version || "—"}</td>
<td className="px-3 py-2">{formatDate(node.last_seen)}</td> <td className="px-3 py-2">{formatDate(node.last_seen)}</td>
<td className="px-3 py-2">{formatUnknown(node.notes)}</td> <td className="px-3 py-2 break-words">{formatUnknown(node.notes)}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="grid gap-3 md:hidden">
{sortedItems.map((node, index) => (
<div
key={node.public_key ?? node.ip ?? `node-card-${index}`}
className="rounded-2xl border border-slate-800 bg-slate-950/50 p-4 shadow-inner shadow-black/30"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="text-xs uppercase tracking-wide text-slate-500">IP</span>
<span className="font-mono text-xs text-slate-200">{node.ip ?? "—"}</span>
{node.ip ? <CopyButton value={node.ip} aria-label="Скопировать IP-адрес" successMessage="IP-адрес узла скопирован" /> : null}
</div>
<span className="rounded-full bg-slate-900/80 px-3 py-1 text-[11px] uppercase tracking-wide text-slate-300">
{node.role}
</span>
</div>
<div className="mt-3 grid gap-3 text-xs text-slate-300">
<div className="flex flex-wrap items-center gap-2">
<span className="text-slate-500">Порт</span>
<span className="font-mono text-xs text-slate-200">{node.port ?? "—"}</span>
{node.port != null ? (
<CopyButton value={node.port} aria-label="Скопировать порт" successMessage="Порт узла скопирован" />
) : null}
</div>
<div className="flex flex-col gap-2">
<span className="text-slate-500">Публичный ключ</span>
<div className="flex items-center gap-2">
<span className="break-all font-mono text-[11px] text-slate-300">{node.public_key ?? "—"}</span>
{node.public_key ? (
<CopyButton
value={node.public_key}
aria-label="Скопировать публичный ключ"
successMessage="Публичный ключ узла скопирован"
/>
) : null}
</div>
</div>
<div className="flex flex-col gap-1">
<span className="text-slate-500">Версия</span>
<span>{node.version || "—"}</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-slate-500">Последний онлайн</span>
<span>{formatDate(node.last_seen)}</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-slate-500">Заметки</span>
<span className="break-words text-slate-200">{formatUnknown(node.notes)}</span>
</div>
</div>
<div className="mt-3">
<label className="text-xs uppercase tracking-wide text-slate-500">Роль</label>
<select
className="mt-1 w-full rounded-lg border border-slate-700 bg-slate-950 px-2 py-2 text-xs text-slate-100 focus:border-sky-500 focus:outline-none"
value={node.role}
onChange={handleRoleChange(node.public_key, node.ip)}
disabled={nodeRoleMutation.isLoading}
>
<option value="trusted">trusted</option>
<option value="read-only">read-only</option>
<option value="deny">deny</option>
</select>
</div>
</div>
))}
</div>
</Section> </Section>
); );
}; };

View File

@ -1,7 +1,7 @@
import { useMemo } from "react"; import { useMemo } from "react";
import { useAdminOverview } from "~/shared/services/admin"; import { useAdminOverview } from "~/shared/services/admin";
import { Section, Badge, InfoRow } from "../components"; import { Section, Badge, InfoRow, CopyButton } from "../components";
import { useAdminContext } from "../context"; import { useAdminContext } from "../context";
import { formatBytes, formatDate, formatUnknown, numberFormatter } from "../utils/format"; import { formatBytes, formatDate, formatUnknown, numberFormatter } from "../utils/format";
@ -28,16 +28,22 @@ export const AdminOverviewPage = () => {
label: "Хост", label: "Хост",
value: project.host || "локальный", value: project.host || "локальный",
helper: project.name, helper: project.name,
copyValue: project.host ?? null,
successMessage: "Хост скопирован",
}, },
{ {
label: "TON Master", label: "TON Master",
value: node.ton_master, value: node.ton_master,
helper: "Платформа", helper: "Платформа",
copyValue: node.ton_master ?? null,
successMessage: "TON Master скопирован",
}, },
{ {
label: "Service Wallet", label: "Service Wallet",
value: node.service_wallet, value: node.service_wallet,
helper: node.id, helper: node.id,
copyValue: node.service_wallet ?? null,
successMessage: "Service wallet скопирован",
}, },
{ {
label: "Контент", label: "Контент",
@ -58,6 +64,8 @@ export const AdminOverviewPage = () => {
label: "Билд", label: "Билд",
value: codebase.commit ?? "n/a", value: codebase.commit ?? "n/a",
helper: codebase.branch ?? "", helper: codebase.branch ?? "",
copyValue: codebase.commit ?? null,
successMessage: "Хеш коммита скопирован",
}, },
{ {
label: "Python", label: "Python",
@ -76,6 +84,11 @@ export const AdminOverviewPage = () => {
} }
const { project, services, ton, runtime, ipfs } = overviewQuery.data; const { project, services, ton, runtime, ipfs } = overviewQuery.data;
const ipfsIdentity = (ipfs.identity ?? {}) as Record<string, unknown>;
const ipfsBitswap = (ipfs.bitswap ?? {}) as Record<string, unknown>;
const ipfsRepo = (ipfs.repo ?? {}) as Record<string, unknown>;
const ipfsId = ipfsIdentity["ID"];
const ipfsAgent = ipfsIdentity["AgentVersion"];
return ( return (
<Section <Section
@ -95,10 +108,22 @@ export const AdminOverviewPage = () => {
> >
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{overviewCards.map((card) => ( {overviewCards.map((card) => (
<div key={card.label} className="rounded-xl bg-slate-950/60 p-4 shadow-inner shadow-black/40 ring-1 ring-slate-800"> <div
<p className="text-xs uppercase tracking-wide text-slate-500">{card.label}</p> key={card.label}
className="overflow-hidden rounded-xl bg-slate-950/60 p-4 shadow-inner shadow-black/40 ring-1 ring-slate-800"
>
<div className="flex items-start justify-between gap-2">
<p className="text-xs uppercase tracking-wide text-slate-500">{card.label}</p>
{card.copyValue ? (
<CopyButton
value={card.copyValue}
successMessage={card.successMessage}
aria-label={`Скопировать значение «${card.label}»`}
/>
) : null}
</div>
<p className="mt-2 break-all text-lg font-semibold text-slate-100">{card.value}</p> <p className="mt-2 break-all text-lg font-semibold text-slate-100">{card.value}</p>
{card.helper ? <p className="mt-1 text-xs text-slate-500">{card.helper}</p> : null} {card.helper ? <p className="mt-1 break-words text-xs text-slate-500">{card.helper}</p> : null}
</div> </div>
))} ))}
</div> </div>
@ -107,12 +132,16 @@ export const AdminOverviewPage = () => {
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-lg font-semibold text-slate-100">Проект</h3> <h3 className="text-lg font-semibold text-slate-100">Проект</h3>
<dl className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <dl className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<InfoRow label="Хост">{project.host || "—"}</InfoRow> <InfoRow label="Хост" copyValue={project.host ?? null}>
{project.host || "—"}
</InfoRow>
<InfoRow label="Имя">{project.name}</InfoRow> <InfoRow label="Имя">{project.name}</InfoRow>
<InfoRow label="Приватность">{project.privacy}</InfoRow> <InfoRow label="Приватность">{project.privacy}</InfoRow>
<InfoRow label="TON Testnet">{ton.testnet ? "Да" : "Нет"}</InfoRow> <InfoRow label="TON Testnet">{ton.testnet ? "Да" : "Нет"}</InfoRow>
<InfoRow label="TON API Key">{ton.api_key_configured ? "Настроен" : "Нет"}</InfoRow> <InfoRow label="TON API Key">{ton.api_key_configured ? "Настроен" : "Нет"}</InfoRow>
<InfoRow label="TON Host">{ton.host || "—"}</InfoRow> <InfoRow label="TON Host" copyValue={ton.host ?? null}>
{ton.host || "—"}
</InfoRow>
</dl> </dl>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
@ -129,12 +158,20 @@ export const AdminOverviewPage = () => {
<div className="grid gap-6 lg:grid-cols-2"> <div className="grid gap-6 lg:grid-cols-2">
<div className="space-y-3"> <div className="space-y-3">
<h3 className="text-lg font-semibold text-slate-100">IPFS</h3> <h3 className="text-lg font-semibold text-slate-100">IPFS</h3>
<div className="grid gap-3 rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800"> <div className="grid gap-3 overflow-hidden rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
<InfoRow label="ID">{formatUnknown((ipfs.identity as Record<string, unknown>)?.ID)}</InfoRow> <InfoRow label="ID" copyValue={(ipfsId as string) ?? null}>
<InfoRow label="Agent">{formatUnknown((ipfs.identity as Record<string, unknown>)?.AgentVersion)}</InfoRow> {formatUnknown(ipfsId)}
<InfoRow label="Peers">{numberFormatter.format(Number((ipfs.bitswap as Record<string, unknown>)?.Peers ?? 0))}</InfoRow> </InfoRow>
<InfoRow label="Repo size">{formatBytes(Number((ipfs.repo as Record<string, unknown>)?.RepoSize ?? 0))}</InfoRow> <InfoRow label="Agent">{formatUnknown(ipfsAgent)}</InfoRow>
<InfoRow label="Storage max">{formatBytes(Number((ipfs.repo as Record<string, unknown>)?.StorageMax ?? 0))}</InfoRow> <InfoRow label="Peers">
{numberFormatter.format(Number(ipfsBitswap?.Peers ?? 0))}
</InfoRow>
<InfoRow label="Repo size">
{formatBytes(Number(ipfsRepo?.RepoSize ?? 0))}
</InfoRow>
<InfoRow label="Storage max">
{formatBytes(Number(ipfsRepo?.StorageMax ?? 0))}
</InfoRow>
</div> </div>
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
@ -155,7 +192,7 @@ export const AdminOverviewPage = () => {
key={service.name} key={service.name}
className="flex items-center justify-between rounded-lg bg-slate-950/40 px-3 py-2 ring-1 ring-slate-800" className="flex items-center justify-between rounded-lg bg-slate-950/40 px-3 py-2 ring-1 ring-slate-800"
> >
<span className="text-sm font-medium text-slate-100">{service.name}</span> <span className="max-w-[50%] break-words text-sm font-medium text-slate-100">{service.name}</span>
<div className="flex items-center gap-3 text-xs text-slate-400"> <div className="flex items-center gap-3 text-xs text-slate-400">
<span> <span>
{service.last_reported_seconds != null {service.last_reported_seconds != null

View File

@ -110,7 +110,7 @@ export const AdminStatusPage = () => {
<Section id="status" title="Статус & лимиты" description="Информация о синхронизации, кэше и лимитах"> <Section id="status" title="Статус & лимиты" description="Информация о синхронизации, кэше и лимитах">
<div className="grid gap-6 xl:grid-cols-[1.8fr_1fr]"> <div className="grid gap-6 xl:grid-cols-[1.8fr_1fr]">
<div className="space-y-6"> <div className="space-y-6">
<div className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800"> <div className="overflow-hidden 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">IPFS</h3> <h3 className="text-sm font-semibold uppercase tracking-wide text-slate-400">IPFS</h3>
<dl className="mt-3 grid grid-cols-2 gap-3 text-sm"> <dl className="mt-3 grid grid-cols-2 gap-3 text-sm">
<InfoRow label="Repo Size"> <InfoRow label="Repo Size">
@ -127,7 +127,7 @@ export const AdminStatusPage = () => {
</InfoRow> </InfoRow>
</dl> </dl>
</div> </div>
<div className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800"> <div className="overflow-hidden 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">Pin-статистика</h3> <h3 className="text-sm font-semibold uppercase tracking-wide text-slate-400">Pin-статистика</h3>
<dl className="mt-3 grid grid-cols-2 gap-3 text-sm"> <dl className="mt-3 grid grid-cols-2 gap-3 text-sm">
{Object.entries(pin_counts).map(([key, value]) => ( {Object.entries(pin_counts).map(([key, value]) => (
@ -142,7 +142,7 @@ export const AdminStatusPage = () => {
</div> </div>
<div className="space-y-6"> <div className="space-y-6">
<form <form
className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800" className="overflow-hidden rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800"
onSubmit={cacheLimitsForm.handleSubmit((values) => { onSubmit={cacheLimitsForm.handleSubmit((values) => {
cacheLimitsMutation.mutate(values); cacheLimitsMutation.mutate(values);
})} })}
@ -177,7 +177,7 @@ export const AdminStatusPage = () => {
</form> </form>
<form <form
className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800" className="overflow-hidden rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800"
onSubmit={cacheFitForm.handleSubmit((values) => { onSubmit={cacheFitForm.handleSubmit((values) => {
cacheCleanupMutation.mutate({ mode: "fit", max_gb: values.max_gb }); cacheCleanupMutation.mutate({ mode: "fit", max_gb: values.max_gb });
})} })}
@ -212,7 +212,7 @@ export const AdminStatusPage = () => {
</form> </form>
<form <form
className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800" className="overflow-hidden rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800"
onSubmit={syncLimitsForm.handleSubmit((values) => { onSubmit={syncLimitsForm.handleSubmit((values) => {
syncLimitsMutation.mutate(values); syncLimitsMutation.mutate(values);
})} })}

View File

@ -1,5 +1,5 @@
import { useAdminStorage } from "~/shared/services/admin"; import { useAdminStorage } from "~/shared/services/admin";
import { Section, Badge, InfoRow } from "../components"; import { Section, Badge, InfoRow, CopyButton } from "../components";
import { useAdminContext } from "../context"; import { useAdminContext } from "../context";
import { formatBytes, numberFormatter } from "../utils/format"; import { formatBytes, numberFormatter } from "../utils/format";
@ -30,7 +30,7 @@ export const AdminStoragePage = () => {
return ( return (
<Section id="storage" title="Хранилище" description="Пути на диске, использование и кэш деривативов"> <Section id="storage" title="Хранилище" description="Пути на диске, использование и кэш деривативов">
<div className="overflow-x-auto"> <div className="hidden overflow-x-auto rounded-2xl border border-slate-800 md:block">
<table className="min-w-full divide-y divide-slate-800 text-left text-sm"> <table className="min-w-full divide-y divide-slate-800 text-left text-sm">
<thead className="bg-slate-950/70 text-xs uppercase tracking-wide text-slate-400"> <thead className="bg-slate-950/70 text-xs uppercase tracking-wide text-slate-400">
<tr> <tr>
@ -43,7 +43,17 @@ export const AdminStoragePage = () => {
<tbody className="divide-y divide-slate-900/60"> <tbody className="divide-y divide-slate-900/60">
{directories.map((dir) => ( {directories.map((dir) => (
<tr key={dir.path} className="hover:bg-slate-900/50"> <tr key={dir.path} className="hover:bg-slate-900/50">
<td className="px-3 py-2 font-mono text-xs text-slate-300">{dir.path}</td> <td className="px-3 py-2">
<div className="flex items-center gap-2">
<span className="break-all font-mono text-xs text-slate-300">{dir.path}</span>
<CopyButton
value={dir.path}
aria-label="Скопировать путь"
successMessage="Путь скопирован"
className="h-6 w-6"
/>
</div>
</td>
<td className="px-3 py-2">{numberFormatter.format(dir.file_count)}</td> <td className="px-3 py-2">{numberFormatter.format(dir.file_count)}</td>
<td className="px-3 py-2">{formatBytes(dir.size_bytes)}</td> <td className="px-3 py-2">{formatBytes(dir.size_bytes)}</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
@ -54,11 +64,41 @@ export const AdminStoragePage = () => {
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="grid gap-3 md:hidden">
{directories.map((dir) => (
<div key={`dir-card-${dir.path}`} className="rounded-2xl border border-slate-800 bg-slate-950/40 p-4 shadow-inner shadow-black/30">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2">
<span className="break-all font-mono text-[11px] text-slate-200">{dir.path}</span>
<CopyButton
value={dir.path}
aria-label="Скопировать путь"
successMessage="Путь скопирован"
className="h-6 w-6"
/>
</div>
<Badge tone={dir.exists ? "success" : "danger"}>{dir.exists ? "OK" : "Нет"}</Badge>
</div>
<div className="mt-3 grid grid-cols-2 gap-3 text-xs text-slate-300">
<div>
<span className="text-slate-500">Файлов</span>
<div>{numberFormatter.format(dir.file_count)}</div>
</div>
<div>
<span className="text-slate-500">Размер</span>
<div>{formatBytes(dir.size_bytes)}</div>
</div>
</div>
</div>
))}
</div>
{disk ? ( {disk ? (
<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>
<div className="mt-2 grid grid-cols-2 gap-3 text-sm"> <div className="mt-2 grid grid-cols-2 gap-3 text-sm">
<InfoRow label="Путь">{disk.path}</InfoRow> <InfoRow label="Путь" copyValue={disk.path}>
{disk.path}
</InfoRow>
<InfoRow label="Свободно">{formatBytes(disk.free_bytes)}</InfoRow> <InfoRow label="Свободно">{formatBytes(disk.free_bytes)}</InfoRow>
<InfoRow label="Занято">{formatBytes(disk.used_bytes)}</InfoRow> <InfoRow label="Занято">{formatBytes(disk.used_bytes)}</InfoRow>
<InfoRow label="Всего">{formatBytes(disk.total_bytes)}</InfoRow> <InfoRow label="Всего">{formatBytes(disk.total_bytes)}</InfoRow>

View File

@ -6,7 +6,7 @@ import {
type AdminUploadsContent, type AdminUploadsContent,
type AdminUploadsContentFlags, type AdminUploadsContentFlags,
} from "~/shared/services/admin"; } from "~/shared/services/admin";
import { Section, Badge, InfoRow } from "../components"; import { Section, Badge, InfoRow, CopyButton } from "../components";
import { useAdminContext } from "../context"; import { useAdminContext } from "../context";
import { formatBytes, formatDate, numberFormatter } from "../utils/format"; import { formatBytes, formatDate, numberFormatter } from "../utils/format";
@ -168,7 +168,7 @@ const UploadCard = ({ decoration }: { decoration: UploadDecoration }) => {
const derivativeDownloads = item.links.download_derivatives ?? []; const derivativeDownloads = item.links.download_derivatives ?? [];
return ( return (
<div className="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">
<div className="flex flex-wrap items-start justify-between gap-3"> <div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<h3 className="text-lg font-semibold text-slate-100">{item.title || "Без названия"}</h3> <h3 className="text-lg font-semibold text-slate-100">{item.title || "Без названия"}</h3>
@ -192,13 +192,13 @@ const UploadCard = ({ decoration }: { decoration: UploadDecoration }) => {
<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">On-chain</h4> <h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">On-chain</h4>
<div className="mt-2 grid gap-2 text-xs text-slate-300 sm:grid-cols-2"> <div className="mt-2 grid gap-2 text-xs text-slate-300 sm:grid-cols-2">
<InfoRow label="Encrypted CID"> <InfoRow label="Encrypted CID" copyValue={item.encrypted_cid}>
<span className="break-all font-mono">{item.encrypted_cid}</span> <span className="break-all font-mono">{item.encrypted_cid}</span>
</InfoRow> </InfoRow>
<InfoRow label="Metadata CID"> <InfoRow label="Metadata CID" copyValue={item.metadata_cid ?? null}>
<span className="break-all font-mono">{item.metadata_cid ?? "—"}</span> <span className="break-all font-mono">{item.metadata_cid ?? "—"}</span>
</InfoRow> </InfoRow>
<InfoRow label="Content hash"> <InfoRow label="Content hash" copyValue={item.content_hash ?? null}>
<span className="break-all font-mono">{item.content_hash ?? "—"}</span> <span className="break-all font-mono">{item.content_hash ?? "—"}</span>
</InfoRow> </InfoRow>
<InfoRow label="On-chain индекс"> <InfoRow label="On-chain индекс">
@ -211,7 +211,7 @@ const UploadCard = ({ decoration }: { decoration: UploadDecoration }) => {
<Badge tone="warn">не опубликовано</Badge> <Badge tone="warn">не опубликовано</Badge>
)} )}
</InfoRow> </InfoRow>
<InfoRow label="On-chain адрес"> <InfoRow label="On-chain адрес" copyValue={item.status.onchain?.item_address ?? null}>
<span className="break-all font-mono text-xs">{item.status.onchain?.item_address ?? "—"}</span> <span className="break-all font-mono text-xs">{item.status.onchain?.item_address ?? "—"}</span>
</InfoRow> </InfoRow>
</div> </div>
@ -316,11 +316,48 @@ const UploadCard = ({ decoration }: { decoration: UploadDecoration }) => {
<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>
{item.stored ? ( {item.stored ? (
<div className="mt-2 space-y-1 text-xs text-slate-300"> <div className="mt-2 space-y-1 text-xs text-slate-300">
<div>ID: {item.stored.stored_id ?? "—"}</div> <div className="flex flex-wrap items-center gap-2">
<div>Владелец: {item.stored.owner_address ?? "—"}</div> <span className="font-semibold text-slate-200">ID:</span>
<span className="break-all font-mono">{item.stored.stored_id ?? "—"}</span>
{item.stored.stored_id ? (
<CopyButton
value={item.stored.stored_id}
aria-label="Скопировать ID хранения"
successMessage="ID хранения скопирован"
className="h-6 w-6"
/>
) : null}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="font-semibold text-slate-200">Владелец:</span>
<span className="break-all font-mono text-[11px] text-slate-300">{item.stored.owner_address ?? "—"}</span>
{item.stored.owner_address ? (
<CopyButton
value={item.stored.owner_address}
aria-label="Скопировать адрес владельца хранения"
successMessage="Адрес владельца скопирован"
className="h-6 w-6"
/>
) : null}
</div>
<div>Тип: {item.stored.type ?? "—"}</div> <div>Тип: {item.stored.type ?? "—"}</div>
{item.stored.user ? ( {item.stored.user ? (
<div className="text-slate-400">Пользователь #{item.stored.user.id} · TG {item.stored.user.telegram_id}</div> <div className="flex flex-wrap items-center gap-2 text-slate-400">
<span>Пользователь #{item.stored.user.id}</span>
<CopyButton
value={item.stored.user.id}
aria-label="Скопировать ID пользователя"
successMessage="ID пользователя скопирован"
className="h-6 w-6"
/>
<span>· TG {item.stored.user.telegram_id}</span>
<CopyButton
value={item.stored.user.telegram_id}
aria-label="Скопировать Telegram ID"
successMessage="Telegram ID скопирован"
className="h-6 w-6"
/>
</div>
) : null} ) : null}
{item.stored.download_url ? ( {item.stored.download_url ? (
<a <a
@ -455,17 +492,17 @@ export const AdminUploadsPage = () => {
> >
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{Object.entries(states ?? {}).map(([key, value]) => ( {Object.entries(states ?? {}).map(([key, value]) => (
<div key={key} className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800"> <div key={key} className="overflow-hidden rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
<p className="text-xs uppercase tracking-wide text-slate-500">{key}</p> <p className="text-xs uppercase tracking-wide text-slate-500">{key}</p>
<p className="mt-2 text-lg font-semibold text-slate-100">{numberFormatter.format(value)}</p> <p className="mt-2 text-lg font-semibold text-slate-100">{numberFormatter.format(value)}</p>
</div> </div>
))} ))}
<div className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800"> <div className="overflow-hidden rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
<p className="text-xs uppercase tracking-wide text-slate-500">Всего</p> <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-2 text-lg font-semibold text-slate-100">{numberFormatter.format(total)}</p>
<p className="mt-1 text-xs text-slate-500">Отфильтровано: {numberFormatter.format(filteredContents.length)}</p> <p className="mt-1 text-xs text-slate-500">Отфильтровано: {numberFormatter.format(filteredContents.length)}</p>
</div> </div>
<div className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800"> <div className="overflow-hidden rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800">
<p className="text-xs uppercase tracking-wide text-slate-500">Активные задачи</p> <p className="text-xs uppercase tracking-wide text-slate-500">Активные задачи</p>
<p className="mt-2 text-lg font-semibold text-slate-100">{numberCompact.format(categoryCounts.processing)}</p> <p className="mt-2 text-lg font-semibold text-slate-100">{numberCompact.format(categoryCounts.processing)}</p>
<p className="mt-1 text-xs text-slate-500">{numberFormatter.format(categoryCounts.issues)} с ошибками</p> <p className="mt-1 text-xs text-slate-500">{numberFormatter.format(categoryCounts.issues)} с ошибками</p>

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useAdminUsers } from "~/shared/services/admin"; import { useAdminUsers } from "~/shared/services/admin";
import { Section, PaginationControls } from "../components"; import { Section, PaginationControls, CopyButton } from "../components";
import { useAdminContext } from "../context"; import { useAdminContext } from "../context";
import { formatDate, formatStars, numberFormatter } from "../utils/format"; import { formatDate, formatStars, numberFormatter } from "../utils/format";
@ -88,7 +88,10 @@ export const AdminUsersPage = () => {
> >
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{summaryCards.map((card) => ( {summaryCards.map((card) => (
<div key={card.label} className="rounded-xl bg-slate-950/60 p-4 ring-1 ring-slate-800 shadow-inner shadow-black/40"> <div
key={card.label}
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">{card.label}</p> <p className="text-xs uppercase tracking-wide text-slate-500">{card.label}</p>
<p className="mt-2 text-lg font-semibold text-slate-100">{card.value}</p> <p className="mt-2 text-lg font-semibold text-slate-100">{card.value}</p>
{card.helper ? <p className="mt-1 text-xs text-slate-500">{card.helper}</p> : null} {card.helper ? <p className="mt-1 text-xs text-slate-500">{card.helper}</p> : null}
@ -117,7 +120,7 @@ export const AdminUsersPage = () => {
</div> </div>
</div> </div>
<div className="mt-4 overflow-x-auto"> <div className="mt-4 hidden overflow-x-auto rounded-2xl border border-slate-800 md:block">
<table className="min-w-full divide-y divide-slate-800 text-left text-sm"> <table className="min-w-full divide-y divide-slate-800 text-left text-sm">
<thead className="bg-slate-950/70 text-xs uppercase tracking-wide text-slate-400"> <thead className="bg-slate-950/70 text-xs uppercase tracking-wide text-slate-400">
<tr> <tr>
@ -141,8 +144,21 @@ export const AdminUsersPage = () => {
<tr key={`admin-user-${user.id}`} className="hover:bg-slate-900/40"> <tr key={`admin-user-${user.id}`} className="hover:bg-slate-900/40">
<td className="px-3 py-3 align-top"> <td className="px-3 py-3 align-top">
<div className="font-semibold text-slate-100">{user.username ? `@${user.username}` : "Без username"}</div> <div className="font-semibold text-slate-100">{user.username ? `@${user.username}` : "Без username"}</div>
<div className="text-xs text-slate-400"> <div className="flex flex-wrap items-center gap-2 text-xs text-slate-400">
ID {numberFormatter.format(user.id)} · TG {numberFormatter.format(user.telegram_id)} <span>ID {numberFormatter.format(user.id)}</span>
<CopyButton
value={user.id}
aria-label="Скопировать ID пользователя"
successMessage="ID пользователя скопирован"
className="h-6 w-6"
/>
<span>· TG {numberFormatter.format(user.telegram_id)}</span>
<CopyButton
value={user.telegram_id}
aria-label="Скопировать Telegram ID"
successMessage="Telegram ID скопирован"
className="h-6 w-6"
/>
</div> </div>
{user.first_name || user.last_name ? ( {user.first_name || user.last_name ? (
<div className="text-xs text-slate-500"> <div className="text-xs text-slate-500">
@ -155,7 +171,17 @@ export const AdminUsersPage = () => {
Активных {numberFormatter.format(user.wallets.active_count)} / всего{" "} Активных {numberFormatter.format(user.wallets.active_count)} / всего{" "}
{numberFormatter.format(user.wallets.total_count)} {numberFormatter.format(user.wallets.total_count)}
</div> </div>
<div className="mt-1 break-all text-xs text-slate-300">{user.wallets.primary_address ?? "—"}</div> <div className="mt-1 flex flex-wrap items-center gap-2 break-all text-xs text-slate-300">
<span className="break-all">{user.wallets.primary_address ?? "—"}</span>
{user.wallets.primary_address ? (
<CopyButton
value={user.wallets.primary_address}
aria-label="Скопировать адрес кошелька"
successMessage="Адрес кошелька скопирован"
className="h-6 w-6"
/>
) : null}
</div>
</td> </td>
<td className="px-3 py-3 align-top"> <td className="px-3 py-3 align-top">
<div className="font-semibold text-slate-100">{formatStars(user.stars.amount_total)}</div> <div className="font-semibold text-slate-100">{formatStars(user.stars.amount_total)}</div>
@ -168,8 +194,19 @@ export const AdminUsersPage = () => {
</td> </td>
<td className="px-3 py-3 align-top"> <td className="px-3 py-3 align-top">
{user.ip_activity.last ? ( {user.ip_activity.last ? (
<div className="text-xs text-slate-300"> <div className="flex flex-col gap-1 text-xs text-slate-300">
{user.ip_activity.last.ip ?? "—"} · {formatDate(user.ip_activity.last.seen_at)} <div className="flex flex-wrap items-center gap-2">
<span className="break-all">{user.ip_activity.last.ip ?? "—"}</span>
{user.ip_activity.last.ip ? (
<CopyButton
value={user.ip_activity.last.ip}
aria-label="Скопировать IP-адрес"
successMessage="IP-адрес скопирован"
className="h-6 w-6"
/>
) : null}
</div>
<span>{formatDate(user.ip_activity.last.seen_at)}</span>
</div> </div>
) : ( ) : (
<div className="text-xs text-slate-500">Нет данных</div> <div className="text-xs text-slate-500">Нет данных</div>
@ -185,6 +222,105 @@ export const AdminUsersPage = () => {
</tbody> </tbody>
</table> </table>
</div> </div>
<div className="mt-4 grid gap-4 md:hidden">
{items.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>
) : (
items.map((user) => (
<div key={`user-card-${user.id}`} className="rounded-2xl border border-slate-800 bg-slate-950/50 p-4 shadow-inner shadow-black/30">
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold text-slate-100">
<span>{user.username ? `@${user.username}` : "Без username"}</span>
<CopyButton
value={user.id}
aria-label="Скопировать ID пользователя"
successMessage="ID пользователя скопирован"
className="h-6 w-6"
/>
<CopyButton
value={user.telegram_id}
aria-label="Скопировать Telegram ID"
successMessage="Telegram ID скопирован"
className="h-6 w-6"
/>
</div>
{user.first_name || user.last_name ? (
<div className="mt-1 text-xs text-slate-400">
{(user.first_name ?? "")} {(user.last_name ?? "")}
</div>
) : null}
<div className="mt-3 grid gap-3 text-xs text-slate-300">
<div className="flex flex-col gap-1">
<span className="text-slate-500">ID · Telegram</span>
<div className="flex flex-wrap items-center gap-2">
<span>ID {numberFormatter.format(user.id)}</span>
<span>· TG {numberFormatter.format(user.telegram_id)}</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 text-slate-300">
<span>
Активных {numberFormatter.format(user.wallets.active_count)} / всего{" "}
{numberFormatter.format(user.wallets.total_count)}
</span>
{user.wallets.primary_address ? (
<CopyButton
value={user.wallets.primary_address}
aria-label="Скопировать адрес кошелька"
successMessage="Адрес кошелька скопирован"
className="h-6 w-6"
/>
) : null}
</div>
<span className="break-all text-[11px] text-slate-400">{user.wallets.primary_address ?? "—"}</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-slate-500">Stars</span>
<div className="flex flex-col">
<span className="font-semibold text-slate-100">{formatStars(user.stars.amount_total)}</span>
<span>Оплачено: {formatStars(user.stars.amount_paid)}</span>
<span>Неоплачено: {formatStars(user.stars.amount_unpaid)}</span>
</div>
</div>
<div className="flex flex-col gap-1">
<span className="text-slate-500">Лицензии</span>
<span>
Всего {numberFormatter.format(user.licenses.total)} · Активных{" "}
{numberFormatter.format(user.licenses.active)}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-slate-500">Последняя активность</span>
{user.ip_activity.last ? (
<div className="flex flex-col gap-1">
<div className="flex flex-wrap items-center gap-2">
<span className="break-all">{user.ip_activity.last.ip ?? "—"}</span>
{user.ip_activity.last.ip ? (
<CopyButton
value={user.ip_activity.last.ip}
aria-label="Скопировать IP-адрес"
successMessage="IP-адрес скопирован"
className="h-6 w-6"
/>
) : null}
</div>
<span className="text-slate-400">{formatDate(user.ip_activity.last.seen_at)}</span>
</div>
) : (
<span className="text-slate-500">Нет данных</span>
)}
</div>
</div>
<div className="mt-3 text-[11px] text-slate-500">
<div>Создан: {formatDate(user.created_at)}</div>
<div>Последний вход: {formatDate(user.last_use)}</div>
</div>
</div>
))
)}
</div>
<div className="mt-4"> <div className="mt-4">
<PaginationControls total={total} limit={limit} page={page} onPageChange={setPage} /> <PaginationControls total={total} limit={limit} page={page} onPageChange={setPage} />