nice admin

This commit is contained in:
root 2025-10-05 19:07:53 +00:00
parent fb1c015d9a
commit 26783c2d41
2 changed files with 1139 additions and 5 deletions

View File

@ -15,9 +15,12 @@ import {
useAdminOverview, useAdminOverview,
useAdminStatus, useAdminStatus,
useAdminStorage, useAdminStorage,
useAdminStars,
useAdminSyncSetLimits, useAdminSyncSetLimits,
useAdminSystem, useAdminSystem,
useAdminUploads, useAdminUploads,
useAdminLicenses,
useAdminUsers,
} from "~/shared/services/admin"; } from "~/shared/services/admin";
import type { AdminUploadsContent, AdminUploadsContentFlags } from "~/shared/services/admin"; import type { AdminUploadsContent, AdminUploadsContentFlags } from "~/shared/services/admin";
import { clearAdminAuth, getAdminAuthSnapshot } from "~/shared/libs/admin-auth"; import { clearAdminAuth, getAdminAuthSnapshot } from "~/shared/libs/admin-auth";
@ -42,6 +45,11 @@ const formatBytes = (input?: number | null) => {
return `${value.toFixed(value >= 10 || value < 0.1 ? 0 : 1)} ${units[unitIndex]}`; return `${value.toFixed(value >= 10 || value < 0.1 ? 0 : 1)} ${units[unitIndex]}`;
}; };
const formatStars = (input?: number | null) => {
const value = Number(input ?? 0);
return `${numberFormatter.format(value)}`;
};
const formatDate = (iso?: string | null) => { const formatDate = (iso?: string | null) => {
if (!iso) { if (!iso) {
return "—"; return "—";
@ -121,6 +129,55 @@ const Badge = ({ children, tone = "neutral" }: { children: ReactNode; tone?: Bad
); );
}; };
const PaginationControls = ({
total,
limit,
page,
onPageChange,
}: {
total: number;
limit: number;
page: number;
onPageChange: (next: number) => void;
}) => {
const safeLimit = Math.max(limit, 1);
const totalPages = Math.max(1, Math.ceil(Math.max(total, 0) / safeLimit));
const clampedPage = Math.min(Math.max(page, 0), totalPages - 1);
const startIndex = total === 0 ? 0 : clampedPage * safeLimit + 1;
const endIndex = total === 0 ? 0 : Math.min(total, (clampedPage + 1) * safeLimit);
const canGoPrev = clampedPage > 0;
const canGoNext = clampedPage + 1 < totalPages;
return (
<div className="flex flex-col gap-2 rounded-xl bg-slate-950/40 px-3 py-2 ring-1 ring-slate-800 sm:flex-row sm:items-center sm:justify-between">
<div className="text-xs text-slate-400">
{total === 0
? 'Нет записей'
: `Показаны ${numberFormatter.format(startIndex)}${numberFormatter.format(endIndex)} из ${numberFormatter.format(total)}`}
</div>
<div className="flex items-center gap-3">
<button
type="button"
className="rounded-lg bg-slate-900/60 px-3 py-1 text-xs font-semibold text-slate-300 ring-1 ring-slate-700 transition hover:bg-slate-800 disabled:bg-slate-900/30 disabled:text-slate-600 disabled:ring-slate-800"
onClick={() => onPageChange(Math.max(clampedPage - 1, 0))}
disabled={!canGoPrev}
>
Назад
</button>
<span className="text-xs text-slate-300">Стр. {numberFormatter.format(clampedPage + 1)} из {numberFormatter.format(totalPages)}</span>
<button
type="button"
className="rounded-lg bg-slate-900/60 px-3 py-1 text-xs font-semibold text-slate-300 ring-1 ring-slate-700 transition hover:bg-slate-800 disabled:bg-slate-900/30 disabled:text-slate-600 disabled:ring-slate-800"
onClick={() => onPageChange(Math.min(clampedPage + 1, totalPages - 1))}
disabled={!canGoNext}
>
Вперёд
</button>
</div>
</div>
);
};
type FlashMessage = { type FlashMessage = {
type: "success" | "error" | "info"; type: "success" | "error" | "info";
message: string; message: string;
@ -132,6 +189,9 @@ const ADMIN_SECTIONS = [
{ id: "overview", label: "Обзор" }, { id: "overview", label: "Обзор" },
{ id: "storage", label: "Хранилище" }, { id: "storage", label: "Хранилище" },
{ id: "uploads", label: "Загрузки" }, { id: "uploads", label: "Загрузки" },
{ id: "users", label: "Пользователи" },
{ id: "licenses", label: "Лицензии" },
{ id: "stars", label: "Платежи Stars" },
{ id: "system", label: "Система" }, { id: "system", label: "Система" },
{ id: "blockchain", label: "Блокчейн" }, { id: "blockchain", label: "Блокчейн" },
{ id: "nodes", label: "Ноды" }, { id: "nodes", label: "Ноды" },
@ -166,7 +226,545 @@ type UploadDecoration = {
flags: AdminUploadsContentFlags | null; flags: AdminUploadsContentFlags | null;
}; };
const initialAuthState = getAdminAuthSnapshot().token ? "checking" : "unauthorized"; const initialAuthState: AuthState = getAdminAuthSnapshot().token ? "checking" : "unauthorized";
type UsersSectionProps = {
query: ReturnType<typeof useAdminUsers>;
search: string;
onSearchChange: (value: string) => void;
page: number;
limit: number;
onPageChange: (next: number) => void;
onRefresh: () => void;
};
const UsersSection = ({ query, search, onSearchChange, page, limit, onPageChange, onRefresh }: UsersSectionProps) => {
if (query.isLoading && !query.data) {
return <Section id="users" title="Пользователи" description="Мониторинг пользователей, кошельков и активности">Загрузка</Section>;
}
if (!query.data) {
return (
<Section id="users" title="Пользователи" description="Мониторинг пользователей, кошельков и активности">
<p className="text-sm text-slate-400">Выберите вкладку «Пользователи», чтобы загрузить данные.</p>
</Section>
);
}
const { items, summary, total } = query.data;
const summaryCards = [
{ label: 'Всего пользователей', value: numberFormatter.format(total), helper: `На странице: ${numberFormatter.format(summary.users_returned)}` },
{ label: 'Кошельки (активные/всего)', value: `${numberFormatter.format(summary.wallets_active)} / ${numberFormatter.format(summary.wallets_total)}` },
{ label: 'Оплачено Stars', value: formatStars(summary.stars_amount_paid), helper: `${numberFormatter.format(summary.stars_paid)} платежей` },
{ label: 'Неоплачено', value: formatStars(summary.stars_amount_unpaid), helper: `${numberFormatter.format(summary.stars_unpaid)} счетов` },
];
return (
<Section
id="users"
title="Пользователи"
description="Просмотр учетных записей, кошельков и активности"
actions={
<button
type="button"
className="rounded-lg bg-slate-800 px-3 py-2 text-xs font-semibold text-slate-200 ring-1 ring-slate-700 transition hover:bg-slate-700"
onClick={onRefresh}
>
Обновить
</button>
}
>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{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">
<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>
{card.helper ? <p className="mt-1 text-xs text-slate-500">{card.helper}</p> : null}
</div>
))}
</div>
<div className="mt-6 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Поиск</label>
<input
value={search}
onChange={(event) => onSearchChange(event.target.value)}
placeholder="ID, Telegram, username, адрес"
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-500 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 md:w-72"
/>
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="rounded-lg bg-slate-900 px-3 py-2 text-xs font-semibold text-slate-300 ring-1 ring-slate-700 transition hover:bg-slate-800"
onClick={() => onSearchChange('')}
>
Сбросить
</button>
</div>
</div>
<div className="mt-4 overflow-x-auto">
<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">
<tr>
<th className="px-3 py-3">Пользователь</th>
<th className="px-3 py-3">Кошельки</th>
<th className="px-3 py-3">Stars</th>
<th className="px-3 py-3">Лицензии</th>
<th className="px-3 py-3">Активность</th>
<th className="px-3 py-3">Дата</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-900/60">
{items.length === 0 ? (
<tr>
<td colSpan={6} className="px-3 py-6 text-center text-sm text-slate-400">Нет пользователей, удовлетворяющих условиям.</td>
</tr>
) : (
items.map((user) => (
<tr key={`admin-user-${user.id}`} className="hover:bg-slate-900/40">
<td className="px-3 py-3 align-top">
<div className="font-semibold text-slate-100">{user.username ? `@${user.username}` : 'Без username'}</div>
<div className="text-xs text-slate-400">ID {numberFormatter.format(user.id)} · TG {numberFormatter.format(user.telegram_id)}</div>
{(user.first_name || user.last_name) ? (<div className="text-xs text-slate-500">{(user.first_name ?? '')} {(user.last_name ?? '')}</div>) : null}
</td>
<td className="px-3 py-3 align-top">
<div className="text-xs text-slate-400">Активных {numberFormatter.format(user.wallets.active_count)} / всего {numberFormatter.format(user.wallets.total_count)}</div>
<div className="mt-1 break-all text-xs text-slate-300">{user.wallets.primary_address ?? '—'}</div>
</td>
<td className="px-3 py-3 align-top">
<div className="font-semibold text-slate-100">{formatStars(user.stars.amount_total)}</div>
<div className="text-xs text-slate-400">Оплачено: {formatStars(user.stars.amount_paid)}</div>
<div className="text-xs text-slate-400">Неоплачено: {formatStars(user.stars.amount_unpaid)}</div>
</td>
<td className="px-3 py-3 align-top">
<div className="text-xs text-slate-300">Всего {numberFormatter.format(user.licenses.total)}</div>
<div className="text-xs text-slate-300">Активных {numberFormatter.format(user.licenses.active)}</div>
</td>
<td className="px-3 py-3 align-top">
{user.ip_activity.last ? (
<div className="text-xs text-slate-300">{user.ip_activity.last.ip ?? '—'} · {formatDate(user.ip_activity.last.seen_at)}</div>
) : (
<div className="text-xs text-slate-500">Нет данных</div>
)}
</td>
<td className="px-3 py-3 align-top text-xs text-slate-400">
<div>Создан: {formatDate(user.created_at)}</div>
<div>Последний вход: {formatDate(user.last_use)}</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div className="mt-4">
<PaginationControls total={total} limit={limit} page={page} onPageChange={onPageChange} />
</div>
</Section>
);
};
type LicensesSectionProps = {
query: ReturnType<typeof useAdminLicenses>;
search: string;
onSearchChange: (value: string) => void;
statusFilter: string;
onStatusChange: (value: string) => void;
typeFilter: string;
onTypeChange: (value: string) => void;
licenseTypeFilter: string;
onLicenseTypeChange: (value: string) => void;
page: number;
limit: number;
onPageChange: (next: number) => void;
onRefresh: () => void;
};
const LicensesSection = ({
query,
search,
onSearchChange,
statusFilter,
onStatusChange,
typeFilter,
onTypeChange,
licenseTypeFilter,
onLicenseTypeChange,
page,
limit,
onPageChange,
onRefresh,
}: LicensesSectionProps) => {
if (query.isLoading && !query.data) {
return <Section id="licenses" title="Лицензии" description="On-chain лицензии и связанные активы">Загрузка</Section>;
}
if (!query.data) {
return (
<Section id="licenses" title="Лицензии" description="On-chain лицензии и связанные активы">
<p className="text-sm text-slate-400">Выберите вкладку «Лицензии», чтобы загрузить данные.</p>
</Section>
);
}
const { items, counts, total } = query.data;
const statusOptions = Object.keys(counts.status || {});
const typeOptions = Object.keys(counts.type || {});
const kindOptions = Object.keys(counts.license_type || {});
return (
<Section
id="licenses"
title="Лицензии"
description="Привязанные лицензии со сведениями о владельцах и контенте"
actions={
<button
type="button"
className="rounded-lg bg-slate-800 px-3 py-2 text-xs font-semibold text-slate-200 ring-1 ring-slate-700 transition hover:bg-slate-700"
onClick={onRefresh}
>
Обновить
</button>
}
>
<div className="flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div className="flex flex-col gap-2 md:flex-row md:items-end">
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Поиск</label>
<input
value={search}
onChange={(event) => onSearchChange(event.target.value)}
placeholder="Адрес, владелец, hash"
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-500 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 md:w-64"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Статус</label>
<select
value={statusFilter}
onChange={(event) => onStatusChange(event.target.value)}
className="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
>
<option value="all">Все</option>
{statusOptions.map((option) => (
<option key={`license-status-${option}`} value={option}>{option}</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Тип</label>
<select
value={typeFilter}
onChange={(event) => onTypeChange(event.target.value)}
className="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
>
<option value="all">Все</option>
{typeOptions.map((option) => (
<option key={`license-type-${option}`} value={option}>{option}</option>
))}
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">license_type</label>
<select
value={licenseTypeFilter}
onChange={(event) => onLicenseTypeChange(event.target.value)}
className="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
>
<option value="all">Все</option>
{kindOptions.map((option) => (
<option key={`license-kind-${option}`} value={option}>{option === 'unknown' ? 'Не указано' : option}</option>
))}
</select>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="rounded-lg bg-slate-900 px-3 py-2 text-xs font-semibold text-slate-300 ring-1 ring-slate-700 transition hover:bg-slate-800"
onClick={() => {
onSearchChange('');
onStatusChange('all');
onTypeChange('all');
onLicenseTypeChange('all');
}}
>
Сбросить
</button>
</div>
</div>
<div className="mt-4 overflow-x-auto">
<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">
<tr>
<th className="px-3 py-3">Лицензия</th>
<th className="px-3 py-3">On-chain</th>
<th className="px-3 py-3">Пользователь</th>
<th className="px-3 py-3">Контент</th>
<th className="px-3 py-3">Дата</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-900/60">
{items.length === 0 ? (
<tr>
<td colSpan={5} className="px-3 py-6 text-center text-sm text-slate-400">Ничего не найдено.</td>
</tr>
) : (
items.map((license) => (
<tr key={`license-${license.id}`} className="hover:bg-slate-900/40">
<td className="px-3 py-3 align-top">
<div className="font-semibold text-slate-100">#{numberFormatter.format(license.id)}</div>
<div className="text-xs text-slate-400">{license.type ?? '—'} · {license.status ?? '—'}</div>
<div className="text-[10px] uppercase tracking-wide text-slate-500">license_type: {license.license_type ?? '—'}</div>
</td>
<td className="px-3 py-3 align-top">
{license.onchain_address ? (
<a
href={`https://tonviewer.com/${license.onchain_address}`}
target="_blank"
rel="noreferrer"
className="break-all text-xs text-sky-300 hover:text-sky-200"
>
{license.onchain_address}
</a>
) : (
<div className="text-xs text-slate-500"></div>
)}
<div className="text-[10px] uppercase tracking-wide text-slate-500">Владелец: {license.owner_address ?? '—'}</div>
</td>
<td className="px-3 py-3 align-top">
{license.user ? (
<div className="text-xs text-slate-300">ID {numberFormatter.format(license.user.id)} · TG {numberFormatter.format(license.user.telegram_id)}</div>
) : (
<div className="text-xs text-slate-500"></div>
)}
</td>
<td className="px-3 py-3 align-top">
{license.content ? (
<>
<div className="text-xs text-slate-300">{license.content.title}</div>
<div className="break-all text-[10px] uppercase tracking-wide text-slate-500">{license.content.hash}</div>
</>
) : (
<div className="text-xs text-slate-500"></div>
)}
</td>
<td className="px-3 py-3 align-top text-xs text-slate-400">
<div>Создано: {formatDate(license.created_at)}</div>
<div>Обновлено: {formatDate(license.updated_at)}</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div className="mt-4">
<PaginationControls total={total} limit={limit} page={page} onPageChange={onPageChange} />
</div>
</Section>
);
};
type StarsSectionProps = {
query: ReturnType<typeof useAdminStars>;
search: string;
onSearchChange: (value: string) => void;
paidFilter: 'all' | 'paid' | 'unpaid';
onPaidChange: (value: 'all' | 'paid' | 'unpaid') => void;
typeFilter: string;
onTypeChange: (value: string) => void;
page: number;
limit: number;
onPageChange: (next: number) => void;
onRefresh: () => void;
};
const StarsSection = ({
query,
search,
onSearchChange,
paidFilter,
onPaidChange,
typeFilter,
onTypeChange,
page,
limit,
onPageChange,
onRefresh,
}: StarsSectionProps) => {
if (query.isLoading && !query.data) {
return <Section id="stars" title="Платежи Stars" description="Инвойсы и статусы">Загрузка</Section>;
}
if (!query.data) {
return (
<Section id="stars" title="Платежи Stars" description="Инвойсы и статусы">
<p className="text-sm text-slate-400">Выберите вкладку «Платежи Stars», чтобы загрузить данные.</p>
</Section>
);
}
const { items, stats, total } = query.data;
const typeOptions = Object.keys(stats.by_type || {});
const statCards = [
{ label: 'Всего платежей', value: numberFormatter.format(stats.total) },
{ label: 'Оплачено', value: `${numberFormatter.format(stats.paid)} · ${formatStars(stats.amount_paid)}` },
{ label: 'Неоплачено', value: `${numberFormatter.format(stats.unpaid)} · ${formatStars(stats.amount_unpaid)}` },
];
return (
<Section
id="stars"
title="Платежи Stars"
description="Телеграм Stars счета и связанные пользователи"
actions={
<button
type="button"
className="rounded-lg bg-slate-800 px-3 py-2 text-xs font-semibold text-slate-200 ring-1 ring-slate-700 transition hover:bg-slate-700"
onClick={onRefresh}
>
Обновить
</button>
}
>
<div className="grid gap-4 md:grid-cols-3">
{statCards.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">
<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>
</div>
))}
</div>
<div className="mt-6 flex flex-col gap-3 md:flex-row md:items-end md:justify-between">
<div className="flex flex-col gap-2 md:flex-row md:items-end">
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Поиск</label>
<input
value={search}
onChange={(event) => onSearchChange(event.target.value)}
placeholder="ID, ссылка, контент"
className="w-full rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 placeholder:text-slate-500 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500 md:w-64"
/>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Статус</label>
<select
value={paidFilter}
onChange={(event) => onPaidChange(event.target.value as 'all' | 'paid' | 'unpaid')}
className="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
>
<option value="all">Все</option>
<option value="paid">Оплачено</option>
<option value="unpaid">Не оплачено</option>
</select>
</div>
<div className="flex flex-col gap-1">
<label className="text-xs font-semibold uppercase tracking-wide text-slate-400">Тип</label>
<select
value={typeFilter}
onChange={(event) => onTypeChange(event.target.value)}
className="rounded-lg border border-slate-700 bg-slate-900 px-3 py-2 text-sm text-slate-100 focus:border-sky-500 focus:outline-none focus:ring-1 focus:ring-sky-500"
>
<option value="all">Все</option>
{typeOptions.map((option) => (
<option key={`stars-type-${option}`} value={option}>{option}</option>
))}
</select>
</div>
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="rounded-lg bg-slate-900 px-3 py-2 text-xs font-semibold text-slate-300 ring-1 ring-slate-700 transition hover:bg-slate-800"
onClick={() => {
onSearchChange('');
onPaidChange('all');
onTypeChange('all');
}}
>
Сбросить
</button>
</div>
</div>
<div className="mt-4 overflow-x-auto">
<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">
<tr>
<th className="px-3 py-3">Инвойс</th>
<th className="px-3 py-3">Сумма</th>
<th className="px-3 py-3">Пользователь</th>
<th className="px-3 py-3">Контент</th>
<th className="px-3 py-3">Создан</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-900/60">
{items.length === 0 ? (
<tr>
<td colSpan={5} className="px-3 py-6 text-center text-sm text-slate-400">Платежей не найдено.</td>
</tr>
) : (
items.map((invoice) => (
<tr key={`stars-${invoice.id}`} className="hover:bg-slate-900/40">
<td className="px-3 py-3 align-top">
<div className="font-semibold text-slate-100">#{numberFormatter.format(invoice.id)} · {invoice.type ?? '—'}</div>
<div className="text-xs text-slate-400 break-all">{invoice.external_id}</div>
{invoice.invoice_url ? (
<a
href={invoice.invoice_url}
target="_blank"
rel="noreferrer"
className="text-xs text-sky-300 hover:text-sky-200"
>
Открыть счёт
</a>
) : null}
</td>
<td className="px-3 py-3 align-top">
<div className="font-semibold text-slate-100">{formatStars(invoice.amount)}</div>
<Badge tone={invoice.paid ? 'success' : 'warn'}>{invoice.status}</Badge>
</td>
<td className="px-3 py-3 align-top">
{invoice.user ? (
<div className="text-xs text-slate-300">ID {numberFormatter.format(invoice.user.id)} · TG {numberFormatter.format(invoice.user.telegram_id)}</div>
) : (
<div className="text-xs text-slate-500"></div>
)}
</td>
<td className="px-3 py-3 align-top">
{invoice.content ? (
<>
<div className="text-xs text-slate-300">{invoice.content.title}</div>
<div className="break-all text-[10px] uppercase tracking-wide text-slate-500">{invoice.content.hash}</div>
</>
) : (
<div className="text-xs text-slate-500"></div>
)}
</td>
<td className="px-3 py-3 align-top text-xs text-slate-400">{formatDate(invoice.created_at)}</td>
</tr>
))
)}
</tbody>
</table>
</div>
<div className="mt-4">
<PaginationControls total={total} limit={limit} page={page} onPageChange={onPageChange} />
</div>
</Section>
);
};
export const AdminPage = () => { export const AdminPage = () => {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
@ -176,6 +774,25 @@ export const AdminPage = () => {
const [flash, setFlash] = useState<FlashMessage | null>(null); const [flash, setFlash] = useState<FlashMessage | null>(null);
const [uploadsFilter, setUploadsFilter] = useState<UploadFilter>("all"); const [uploadsFilter, setUploadsFilter] = useState<UploadFilter>("all");
const [uploadsSearch, setUploadsSearch] = useState(""); const [uploadsSearch, setUploadsSearch] = useState("");
const [usersPage, setUsersPage] = useState(0);
const [usersSearch, setUsersSearch] = useState("");
const [licensesPage, setLicensesPage] = useState(0);
const [licensesSearch, setLicensesSearch] = useState("");
const [licensesStatusFilter, setLicensesStatusFilter] = useState("all");
const [licensesTypeFilter, setLicensesTypeFilter] = useState("all");
const [licensesLicenseTypeFilter, setLicensesLicenseTypeFilter] = useState("all");
const [starsPage, setStarsPage] = useState(0);
const [starsSearch, setStarsSearch] = useState("");
const [starsPaidFilter, setStarsPaidFilter] = useState<'all' | 'paid' | 'unpaid'>('all');
const [starsTypeFilter, setStarsTypeFilter] = useState("all");
const normalizedUploadsSearch = uploadsSearch.trim();
const normalizedUsersSearch = usersSearch.trim();
const normalizedLicensesSearch = licensesSearch.trim();
const normalizedStarsSearch = starsSearch.trim();
const isUploadsFilterActive = uploadsFilter !== "all";
const hasUploadsSearch = normalizedUploadsSearch.length > 0;
const uploadsScanLimit = isUploadsFilterActive || hasUploadsSearch ? 200 : 100;
useEffect(() => { useEffect(() => {
if (!flash) { if (!flash) {
@ -193,6 +810,18 @@ export const AdminPage = () => {
} }
}, [authState, queryClient]); }, [authState, queryClient]);
useEffect(() => {
setUsersPage(0);
}, [normalizedUsersSearch]);
useEffect(() => {
setLicensesPage(0);
}, [normalizedLicensesSearch, licensesStatusFilter, licensesTypeFilter, licensesLicenseTypeFilter]);
useEffect(() => {
setStarsPage(0);
}, [normalizedStarsSearch, starsPaidFilter, starsTypeFilter]);
const overviewQuery = useAdminOverview({ const overviewQuery = useAdminOverview({
enabled: authState !== "unauthorized", enabled: authState !== "unauthorized",
retry: false, retry: false,
@ -211,10 +840,6 @@ export const AdminPage = () => {
}); });
const isDataEnabled = authState === "authorized"; const isDataEnabled = authState === "authorized";
const normalizedUploadsSearch = uploadsSearch.trim();
const isUploadsFilterActive = uploadsFilter !== "all";
const hasUploadsSearch = normalizedUploadsSearch.length > 0;
const uploadsScanLimit = isUploadsFilterActive || hasUploadsSearch ? 200 : 100;
const storageQuery = useAdminStorage({ const storageQuery = useAdminStorage({
enabled: isDataEnabled, enabled: isDataEnabled,
@ -233,6 +858,55 @@ export const AdminPage = () => {
keepPreviousData: true, keepPreviousData: true,
}, },
); );
const usersLimit = 50;
const usersQuery = useAdminUsers(
{
limit: usersLimit,
offset: usersPage * usersLimit,
search: normalizedUsersSearch || undefined,
},
{
enabled: isDataEnabled && activeSection === "users",
keepPreviousData: true,
refetchInterval: 60_000,
},
);
const licensesLimit = 50;
const licensesQuery = useAdminLicenses(
{
limit: licensesLimit,
offset: licensesPage * licensesLimit,
search: normalizedLicensesSearch || undefined,
type: licensesTypeFilter !== "all" ? licensesTypeFilter : undefined,
status: licensesStatusFilter !== "all" ? licensesStatusFilter : undefined,
license_type: licensesLicenseTypeFilter !== "all" ? licensesLicenseTypeFilter : undefined,
},
{
enabled: isDataEnabled && activeSection === "licenses",
keepPreviousData: true,
refetchInterval: 60_000,
},
);
const starsLimit = 50;
const starsQuery = useAdminStars(
{
limit: starsLimit,
offset: starsPage * starsLimit,
search: normalizedStarsSearch || undefined,
type: starsTypeFilter !== "all" ? starsTypeFilter : undefined,
paid:
starsPaidFilter === 'all'
? undefined
: starsPaidFilter === 'paid'
? true
: false,
},
{
enabled: isDataEnabled && activeSection === "stars",
keepPreviousData: true,
refetchInterval: 60_000,
},
);
const systemQuery = useAdminSystem({ const systemQuery = useAdminSystem({
enabled: isDataEnabled, enabled: isDataEnabled,
refetchInterval: 60_000, refetchInterval: 60_000,
@ -1239,6 +1913,76 @@ export const AdminPage = () => {
</Section> </Section>
); );
}; };
const renderUsers = () => (
<UsersSection
query={usersQuery}
search={usersSearch}
onSearchChange={(value) => {
setUsersSearch(value);
setUsersPage(0);
}}
page={usersPage}
limit={usersLimit}
onPageChange={setUsersPage}
onRefresh={() => usersQuery.refetch()}
/>
);
const renderLicenses = () => (
<LicensesSection
query={licensesQuery}
search={licensesSearch}
onSearchChange={(value) => {
setLicensesSearch(value);
setLicensesPage(0);
}}
statusFilter={licensesStatusFilter}
onStatusChange={(value) => {
setLicensesStatusFilter(value);
setLicensesPage(0);
}}
typeFilter={licensesTypeFilter}
onTypeChange={(value) => {
setLicensesTypeFilter(value);
setLicensesPage(0);
}}
licenseTypeFilter={licensesLicenseTypeFilter}
onLicenseTypeChange={(value) => {
setLicensesLicenseTypeFilter(value);
setLicensesPage(0);
}}
page={licensesPage}
limit={licensesLimit}
onPageChange={setLicensesPage}
onRefresh={() => licensesQuery.refetch()}
/>
);
const renderStars = () => (
<StarsSection
query={starsQuery}
search={starsSearch}
onSearchChange={(value) => {
setStarsSearch(value);
setStarsPage(0);
}}
paidFilter={starsPaidFilter}
onPaidChange={(value) => {
setStarsPaidFilter(value);
setStarsPage(0);
}}
typeFilter={starsTypeFilter}
onTypeChange={(value) => {
setStarsTypeFilter(value);
setStarsPage(0);
}}
page={starsPage}
limit={starsLimit}
onPageChange={setStarsPage}
onRefresh={() => starsQuery.refetch()}
/>
);
const renderSystem = () => { const renderSystem = () => {
if (!systemQuery.data) { if (!systemQuery.data) {
return systemQuery.isLoading ? ( return systemQuery.isLoading ? (
@ -1640,6 +2384,9 @@ export const AdminPage = () => {
{renderOverview()} {renderOverview()}
{renderStorage()} {renderStorage()}
{renderUploads()} {renderUploads()}
{renderUsers()}
{renderLicenses()}
{renderStars()}
{renderSystem()} {renderSystem()}
{renderBlockchain()} {renderBlockchain()}
{renderNodes()} {renderNodes()}

View File

@ -210,6 +210,255 @@ export type AdminUploadsQueryParams = {
type AdminUploadsQueryKey = ['admin', 'uploads', string | null, string | null, number | null, number | null]; type AdminUploadsQueryKey = ['admin', 'uploads', string | null, string | null, number | null, number | null];
export type AdminUserWalletConnectionSummary = {
primary_address: string | null;
active_count: number;
total_count: number;
last_connected_at: string | null;
connections: Array<{
id: number;
address: string;
network: string;
invalidated: boolean;
created_at: string | null;
updated_at: string | null;
}>;
};
export type AdminUserContentSummary = {
total: number;
onchain: number;
local: number;
disabled: number;
};
export type AdminUserLicensesSummary = {
total: number;
active: number;
by_type: Record<string, number>;
by_status: Record<string, number>;
};
export type AdminUserStarsSummary = {
total: number;
paid: number;
unpaid: number;
amount_total: number;
amount_paid: number;
amount_unpaid: number;
};
export type AdminUserIpActivity = {
last: {
ip: string | null;
type: string | null;
seen_at: string | null;
} | null;
unique_ips: number;
recent: Array<{
ip: string | null;
type: string | null;
seen_at: string | null;
}>;
};
export type AdminUserItem = {
id: number;
telegram_id: number;
username: string | null;
first_name: string | null;
last_name: string | null;
lang_code: string | null;
created_at: string | null;
updated_at: string | null;
last_use: string | null;
meta: {
ref_id?: string | null;
referrer_id?: string | null;
};
wallets: AdminUserWalletConnectionSummary;
content: AdminUserContentSummary;
licenses: AdminUserLicensesSummary;
stars: AdminUserStarsSummary;
ip_activity: AdminUserIpActivity;
};
export type AdminUsersSummary = {
users_returned: number;
wallets_total: number;
wallets_active: number;
licenses_total: number;
licenses_active: number;
stars_total: number;
stars_paid: number;
stars_unpaid: number;
stars_amount_total: number;
stars_amount_paid: number;
stars_amount_unpaid: number;
unique_ips_total: number;
};
export type AdminUsersResponse = {
total: number;
limit: number;
offset: number;
search?: string | null;
filters?: Record<string, unknown>;
has_more: boolean;
items: AdminUserItem[];
summary: AdminUsersSummary;
};
export type AdminUsersQueryParams = {
limit?: number;
offset?: number;
search?: string;
};
type AdminUsersQueryKey = ['admin', 'users', string];
export type AdminLicenseItem = {
id: number;
type: string | null;
status: string | null;
license_type: number | null;
onchain_address: string | null;
owner_address: string | null;
user: {
id: number;
telegram_id: number;
username: string | null;
first_name: string | null;
last_name: string | null;
} | null;
wallet_connection: {
id: number;
address: string;
network: string;
invalidated: boolean;
created_at: string | null;
updated_at: string | null;
} | null;
content: {
id: number | null;
hash: string | null;
cid: string | null;
title: string | null;
type: string | null;
owner_address: string | null;
onchain_index: number | null;
user_id: number | null;
download_url: string | null;
} | null;
created_at: string | null;
updated_at: string | null;
meta: Record<string, unknown>;
links: {
tonviewer: string | null;
content_view: string | null;
};
};
export type AdminLicensesCounts = {
status: Record<string, number>;
type: Record<string, number>;
license_type: Record<string, number>;
};
export type AdminLicensesResponse = {
total: number;
limit: number;
offset: number;
search?: string | null;
filters?: Record<string, unknown>;
has_more: boolean;
items: AdminLicenseItem[];
counts: AdminLicensesCounts;
};
export type AdminLicensesQueryParams = {
limit?: number;
offset?: number;
search?: string;
type?: string | string[];
status?: string | string[];
license_type?: number | string | Array<number | string>;
user_id?: number;
owner?: string;
address?: string;
content_hash?: string;
};
type AdminLicensesQueryKey = ['admin', 'licenses', string];
export type AdminStarsInvoiceItem = {
id: number;
external_id: string;
type: string | null;
amount: number;
paid: boolean;
invoice_url: string | null;
created_at: string | null;
status: 'paid' | 'pending';
user: {
id: number;
telegram_id: number;
username: string | null;
first_name: string | null;
last_name: string | null;
} | null;
content: {
id: number | null;
hash: string | null;
cid: string | null;
title: string | null;
onchain_index: number | null;
owner_address: string | null;
user_id: number | null;
download_url: string | null;
} | null;
};
export type AdminStarsStats = {
total: number;
paid: number;
unpaid: number;
amount_total: number;
amount_paid: number;
amount_unpaid: number;
by_type: Record<string, {
total: number;
paid: number;
unpaid: number;
amount_total: number;
amount_paid: number;
amount_unpaid: number;
}>;
};
export type AdminStarsResponse = {
total: number;
limit: number;
offset: number;
search?: string | null;
filters?: Record<string, unknown>;
has_more: boolean;
items: AdminStarsInvoiceItem[];
stats: AdminStarsStats;
};
export type AdminStarsQueryParams = {
limit?: number;
offset?: number;
search?: string;
type?: string | string[];
paid?: boolean;
user_id?: number;
content_hash?: string;
};
type AdminStarsQueryKey = ['admin', 'stars', string];
export type AdminSystemResponse = { export type AdminSystemResponse = {
env: Record<string, string | null | undefined>; env: Record<string, string | null | undefined>;
service_config: Array<{ service_config: Array<{
@ -386,6 +635,144 @@ export const useAdminUploads = (
); );
}; };
export const useAdminUsers = (
params?: AdminUsersQueryParams,
options?: QueryOptions<AdminUsersResponse, AdminUsersQueryKey>,
) => {
const normalizedSearch = params?.search?.trim() || null;
const paramsKeyPayload = {
limit: params?.limit ?? null,
offset: params?.offset ?? null,
search: normalizedSearch,
};
const paramsKey = JSON.stringify(paramsKeyPayload);
const queryParams: Record<string, string | number | undefined> = {
limit: params?.limit,
offset: params?.offset,
search: normalizedSearch ?? undefined,
};
return useQuery<AdminUsersResponse, AxiosError, AdminUsersResponse, AdminUsersQueryKey>(
['admin', 'users', paramsKey],
async () => {
const { data } = await request.get<AdminUsersResponse>('/admin.users', {
params: queryParams,
});
return data;
},
{
...defaultQueryOptions,
...options,
},
);
};
export const useAdminLicenses = (
params?: AdminLicensesQueryParams,
options?: QueryOptions<AdminLicensesResponse, AdminLicensesQueryKey>,
) => {
const normalizedSearch = params?.search?.trim() || null;
const typeValue = Array.isArray(params?.type)
? params.type.filter(Boolean).join(',')
: params?.type ?? null;
const statusValue = Array.isArray(params?.status)
? params.status.filter(Boolean).join(',')
: params?.status ?? null;
const licenseTypeValue = Array.isArray(params?.license_type)
? params.license_type.filter((value) => value !== undefined && value !== null).join(',')
: params?.license_type ?? null;
const paramsKeyPayload = {
limit: params?.limit ?? null,
offset: params?.offset ?? null,
search: normalizedSearch,
type: typeValue,
status: statusValue,
license_type: licenseTypeValue,
user_id: params?.user_id ?? null,
owner: params?.owner ?? null,
address: params?.address ?? null,
content_hash: params?.content_hash ?? null,
};
const paramsKey = JSON.stringify(paramsKeyPayload);
const queryParams: Record<string, string | number | undefined> = {
limit: params?.limit,
offset: params?.offset,
search: normalizedSearch ?? undefined,
type: typeValue ?? undefined,
status: statusValue ?? undefined,
license_type: licenseTypeValue ?? undefined,
user_id: params?.user_id,
owner: params?.owner ?? undefined,
address: params?.address ?? undefined,
content_hash: params?.content_hash ?? undefined,
};
return useQuery<AdminLicensesResponse, AxiosError, AdminLicensesResponse, AdminLicensesQueryKey>(
['admin', 'licenses', paramsKey],
async () => {
const { data } = await request.get<AdminLicensesResponse>('/admin.licenses', {
params: queryParams,
});
return data;
},
{
...defaultQueryOptions,
...options,
},
);
};
export const useAdminStars = (
params?: AdminStarsQueryParams,
options?: QueryOptions<AdminStarsResponse, AdminStarsQueryKey>,
) => {
const normalizedSearch = params?.search?.trim() || null;
const typeValue = Array.isArray(params?.type)
? params.type.filter(Boolean).join(',')
: params?.type ?? null;
const paramsKeyPayload = {
limit: params?.limit ?? null,
offset: params?.offset ?? null,
search: normalizedSearch,
type: typeValue,
paid: typeof params?.paid === 'boolean' ? params.paid : null,
user_id: params?.user_id ?? null,
content_hash: params?.content_hash ?? null,
};
const paramsKey = JSON.stringify(paramsKeyPayload);
const queryParams: Record<string, string | number | undefined> = {
limit: params?.limit,
offset: params?.offset,
search: normalizedSearch ?? undefined,
type: typeValue ?? undefined,
user_id: params?.user_id,
content_hash: params?.content_hash ?? undefined,
};
if (typeof params?.paid === 'boolean') {
queryParams.paid = params.paid ? 1 : 0;
}
return useQuery<AdminStarsResponse, AxiosError, AdminStarsResponse, AdminStarsQueryKey>(
['admin', 'stars', paramsKey],
async () => {
const { data } = await request.get<AdminStarsResponse>('/admin.stars', {
params: queryParams,
});
return data;
},
{
...defaultQueryOptions,
...options,
},
);
};
export const useAdminSystem = ( export const useAdminSystem = (
options?: QueryOptions<AdminSystemResponse, ['admin', 'system']>, options?: QueryOptions<AdminSystemResponse, ['admin', 'system']>,
) => { ) => {