admin improve
This commit is contained in:
parent
82f205bf5c
commit
33e0349a1b
|
|
@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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";
|
||||||
|
|
|
||||||
|
|
@ -30,21 +30,28 @@ 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
|
||||||
|
className={clsx(
|
||||||
|
"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",
|
||||||
|
tone,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex-1 break-words">{flash.message}</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="text-xs font-semibold uppercase tracking-wide text-slate-300 transition hover:text-white"
|
className="text-xs font-semibold uppercase tracking-wide text-slate-200 transition hover:text-white"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
>
|
>
|
||||||
Закрыть
|
Закрыть
|
||||||
</button>
|
</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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
{license.onchain_address ? (
|
{license.onchain_address ? (
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
<a
|
<a
|
||||||
href={`https://tonviewer.com/${license.onchain_address}`}
|
href={`https://tonviewer.com/${license.onchain_address}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="break-all text-xs text-sky-300 hover:text-sky-200"
|
className="break-all text-sky-300 hover:text-sky-200"
|
||||||
>
|
>
|
||||||
{license.onchain_address}
|
{license.onchain_address}
|
||||||
</a>
|
</a>
|
||||||
|
<CopyButton
|
||||||
|
value={license.onchain_address}
|
||||||
|
aria-label="Скопировать on-chain адрес"
|
||||||
|
successMessage="On-chain адрес скопирован"
|
||||||
|
className="h-6 w-6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-xs text-slate-500">—</div>
|
<div className="text-xs text-slate-500">—</div>
|
||||||
)}
|
)}
|
||||||
<div className="text-[10px] uppercase tracking-wide text-slate-500">
|
<div className="flex items-center gap-2 text-[10px] uppercase tracking-wide text-slate-500">
|
||||||
|
<span className="break-all">
|
||||||
Владелец: {license.owner_address ?? "—"}
|
Владелец: {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} />
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
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>
|
<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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
})}
|
})}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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} />
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue