improve system

This commit is contained in:
root 2025-10-01 12:27:04 +00:00
parent abbd6ec9be
commit 214ac28926
6 changed files with 823 additions and 62 deletions

View File

@ -19,6 +19,7 @@ import {
useAdminSystem,
useAdminUploads,
} from "~/shared/services/admin";
import type { AdminUploadsContent, AdminUploadsContentFlags } from "~/shared/services/admin";
import { clearAdminAuth, getAdminAuthSnapshot } from "~/shared/libs/admin-auth";
const numberFormatter = new Intl.NumberFormat("ru-RU");
@ -151,6 +152,20 @@ type SyncLimitsFormValues = {
disk_low_watermark_pct: number;
};
type UploadCategory = "issues" | "processing" | "ready" | "unindexed";
type UploadFilter = "all" | UploadCategory;
type UploadDecoration = {
item: AdminUploadsContent;
categories: Set<UploadCategory>;
searchText: string;
hasIssue: boolean;
isReady: boolean;
isProcessing: boolean;
isUnindexed: boolean;
flags: AdminUploadsContentFlags | null;
};
const initialAuthState = getAdminAuthSnapshot().token ? "checking" : "unauthorized";
export const AdminPage = () => {
@ -159,6 +174,8 @@ export const AdminPage = () => {
const [activeSection, setActiveSection] = useState<(typeof ADMIN_SECTIONS)[number]["id"]>("overview");
const [token, setToken] = useState("");
const [flash, setFlash] = useState<FlashMessage | null>(null);
const [uploadsFilter, setUploadsFilter] = useState<UploadFilter>("all");
const [uploadsSearch, setUploadsSearch] = useState("");
useEffect(() => {
if (!flash) {
@ -194,15 +211,28 @@ export const AdminPage = () => {
});
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({
enabled: isDataEnabled,
refetchInterval: 60_000,
});
const uploadsQuery = useAdminUploads({
enabled: isDataEnabled,
refetchInterval: 30_000,
});
const uploadsQuery = useAdminUploads(
{
filter: isUploadsFilterActive ? uploadsFilter : undefined,
search: hasUploadsSearch ? normalizedUploadsSearch : undefined,
limit: 40,
scan: uploadsScanLimit,
},
{
enabled: isDataEnabled,
refetchInterval: 30_000,
keepPreviousData: true,
},
);
const systemQuery = useAdminSystem({
enabled: isDataEnabled,
refetchInterval: 60_000,
@ -509,8 +539,8 @@ export const AdminPage = () => {
<div className="space-y-3">
<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">
<InfoRow label="ID">{(ipfs.identity as Record<string, unknown>)?.ID ?? "—"}</InfoRow>
<InfoRow label="Agent">{(ipfs.identity as Record<string, unknown>)?.AgentVersion ?? "—"}</InfoRow>
<InfoRow label="ID">{formatUnknown((ipfs.identity as Record<string, unknown>)?.ID)}</InfoRow>
<InfoRow label="Agent">{formatUnknown((ipfs.identity as Record<string, unknown>)?.AgentVersion)}</InfoRow>
<InfoRow label="Peers">{numberFormatter.format(Number((ipfs.bitswap as Record<string, unknown>)?.Peers ?? 0))}</InfoRow>
<InfoRow label="Repo size">{formatBytes(Number((ipfs.repo as Record<string, unknown>)?.RepoSize ?? 0))}</InfoRow>
<InfoRow label="Storage max">{formatBytes(Number((ipfs.repo as Record<string, unknown>)?.StorageMax ?? 0))}</InfoRow>
@ -615,17 +645,437 @@ export const AdminPage = () => {
};
const renderUploads = () => {
if (!uploadsQuery.data) {
return uploadsQuery.isLoading ? (
<Section id="uploads" title="Загрузки" description="Статистика сессий">Загрузка</Section>
) : null;
if (uploadsQuery.isLoading && !uploadsQuery.data) {
return <Section id="uploads" title="Загрузки" description="Мониторинг загрузок и конвертации">Загрузка</Section>;
}
const { states, total, recent } = uploadsQuery.data;
if (!uploadsQuery.data) {
return null;
}
const { states, total, recent, contents = [], matching_total: matchingTotalRaw, category_totals: categoryTotalsRaw } = uploadsQuery.data;
const categoryTotals = categoryTotalsRaw ?? {};
const categoryCounts: Record<UploadCategory, number> = {
issues: categoryTotals.issues ?? 0,
processing: categoryTotals.processing ?? 0,
ready: categoryTotals.ready ?? 0,
unindexed: categoryTotals.unindexed ?? 0,
};
const computeFlags = (item: AdminUploadsContent): AdminUploadsContentFlags => {
const normalize = (value: string | null | undefined) => (value ?? '').toLowerCase();
const uploadState = normalize(item.status.upload_state);
const conversionState = normalize(item.status.conversion_state);
const ipfsState = normalize(item.status.ipfs_state);
const pinState = normalize(item.ipfs?.pin_state);
const derivativeStates = item.derivatives.map((derivative) => normalize(derivative.status));
const statusValues = [uploadState, conversionState, ipfsState, pinState, ...derivativeStates];
const hasIssue =
statusValues.some((value) => value.includes('fail') || value.includes('error') || value.includes('timeout')) ||
item.upload_history.some((event) => Boolean(event.error)) ||
item.derivatives.some((derivative) => Boolean(derivative.error)) ||
Boolean(item.ipfs?.pin_error);
const isOnchainIndexed = item.status.onchain?.indexed ?? false;
const isUnindexed = !isOnchainIndexed;
const conversionDone =
conversionState.includes('converted') ||
conversionState.includes('ready') ||
derivativeStates.some((state) => state.includes('ready') || state.includes('converted') || state.includes('complete'));
const ipfsDone =
ipfsState.includes('pinned') ||
ipfsState.includes('ready') ||
pinState.includes('pinned') ||
pinState.includes('ready');
const isReady = !hasIssue && conversionDone && ipfsDone && isOnchainIndexed;
const hasProcessingKeywords =
uploadState.includes('pending') ||
uploadState.includes('process') ||
uploadState.includes('queue') ||
uploadState.includes('upload') ||
conversionState.includes('pending') ||
conversionState.includes('process') ||
conversionState.includes('queue') ||
conversionState.includes('convert') ||
ipfsState.includes('pin') ||
ipfsState.includes('sync') ||
pinState.includes('pin') ||
pinState.includes('sync') ||
derivativeStates.some((state) => state.includes('pending') || state.includes('process') || state.includes('queue'));
const isProcessingCandidate = !isReady && !hasIssue && hasProcessingKeywords;
const isProcessing = isProcessingCandidate || (!isReady && !hasIssue && !isProcessingCandidate);
return {
issues: hasIssue,
processing: isProcessing,
ready: isReady,
unindexed: isUnindexed,
};
};
const classifyUpload = (item: AdminUploadsContent): UploadDecoration => {
const flags = item.flags ?? computeFlags(item);
const categories = new Set<UploadCategory>();
if (flags.issues) {
categories.add('issues');
}
if (flags.processing) {
categories.add('processing');
}
if (flags.ready) {
categories.add('ready');
}
if (flags.unindexed) {
categories.add('unindexed');
}
if (categories.size === 0) {
if (!(item.status.onchain?.indexed ?? false)) {
categories.add('unindexed');
}
categories.add('processing');
}
const searchParts: Array<string | number | null | undefined> = [
item.title,
item.description,
item.encrypted_cid,
item.metadata_cid,
item.content_hash,
item.status.onchain?.item_address,
item.stored?.owner_address,
];
if (item.stored?.user) {
const user = item.stored.user;
searchParts.push(user.id, user.telegram_id, user.username, user.first_name, user.last_name);
}
const searchText = searchParts
.filter((value) => value !== null && value !== undefined && `${value}`.length > 0)
.map((value) => `${value}`.toLowerCase())
.join(' ');
return {
item,
categories,
searchText,
hasIssue: Boolean(flags.issues),
isReady: Boolean(flags.ready),
isProcessing: Boolean(flags.processing),
isUnindexed: Boolean(flags.unindexed),
flags: item.flags ?? flags,
};
};
const decoratedContents = contents.map((item) => classifyUpload(item));
const searchNeedle = normalizedUploadsSearch.toLowerCase();
const filteredEntries = decoratedContents.filter((entry) => {
if (uploadsFilter !== 'all' && !entry.categories.has(uploadsFilter)) {
return false;
}
if (searchNeedle && !entry.searchText.includes(searchNeedle)) {
return false;
}
return true;
});
const filteredContents = filteredEntries.map((entry) => entry.item);
const totalMatches = typeof matchingTotalRaw === 'number' ? matchingTotalRaw : filteredEntries.length;
const filtersActive = isUploadsFilterActive || hasUploadsSearch;
const noMatches = totalMatches === 0;
const issueEntries = filteredEntries.filter((entry) => entry.categories.has('issues'));
const filterOptions: Array<{ id: UploadFilter; label: string; count: number }> = [
{ id: 'all', label: 'Все', count: totalMatches },
{ id: 'issues', label: 'Ошибки', count: categoryCounts.issues },
{ id: 'processing', label: 'В обработке', count: categoryCounts.processing },
{ id: 'ready', label: 'Готово', count: categoryCounts.ready },
{ id: 'unindexed', label: 'Без on-chain', count: categoryCounts.unindexed },
];
const handleResetFilters = () => {
setUploadsFilter('all');
setUploadsSearch('');
};
const getProblemMessages = (item: AdminUploadsContent): string[] => {
const messages: string[] = [];
for (let index = item.upload_history.length - 1; index >= 0; index -= 1) {
const event = item.upload_history[index];
if (event.error) {
messages.push(`Загрузка: ${event.error}`);
break;
}
}
const derivativeError = item.derivatives.find((derivative) => Boolean(derivative.error));
if (derivativeError?.error) {
messages.push(`Дериватив ${derivativeError.kind}: ${derivativeError.error}`);
}
if (item.ipfs?.pin_error) {
messages.push(`IPFS: ${item.ipfs.pin_error}`);
}
const conversionState = item.status.conversion_state;
if (!messages.length && conversionState && conversionState.toLowerCase().includes('fail')) {
messages.push(`Конверсия: ${conversionState}`);
}
const uploadState = item.status.upload_state;
if (!messages.length && uploadState && uploadState.toLowerCase().includes('fail')) {
messages.push(`Загрузка: ${uploadState}`);
}
if (!messages.length) {
messages.push('Причина не указана. Проверьте логи.');
}
return messages;
};
const toneByState = (state: string | null | undefined): BadgeTone => {
const normalized = (state ?? '').toLowerCase();
if (!normalized) {
return 'neutral';
}
if (normalized.includes('fail') || normalized.includes('error') || normalized.includes('timeout')) {
return 'danger';
}
if (
normalized.includes('process') ||
normalized.includes('pending') ||
normalized.includes('pin') ||
normalized.includes('upload') ||
normalized.includes('queue')
) {
return 'warn';
}
if (
normalized.includes('ready') ||
normalized.includes('pinned') ||
normalized.includes('converted') ||
normalized.includes('indexed')
) {
return 'success';
}
return 'neutral';
};
const renderContentCard = (item: AdminUploadsContent) => {
const statusBadges = [
{ label: 'Загрузка', value: item.status.upload_state },
{ label: 'Конверсия', value: item.status.conversion_state },
{ label: 'IPFS', value: item.status.ipfs_state },
{
label: 'On-chain',
value: item.status.onchain?.indexed ? `Индекс ${item.status.onchain.onchain_index ?? '≥8'}` : 'Локально',
toneOverride: item.status.onchain?.indexed ? ('success' as BadgeTone) : ('neutral' as BadgeTone),
},
];
const actionButtons: Array<{ label: string; href: string; download?: boolean }> = [];
if (item.links.web_view) {
actionButtons.push({ label: 'Открыть', href: item.links.web_view });
}
if (item.links.api_view) {
actionButtons.push({ label: 'API', href: item.links.api_view });
}
if (item.links.download_primary) {
actionButtons.push({ label: 'Скачать', href: item.links.download_primary, download: true });
}
const derivativeDownloads = item.links.download_derivatives.filter(
(entry, index, arr) => entry.url && arr.findIndex((comp) => comp.url === entry.url) === index,
);
return (
<div key={item.encrypted_cid} className="rounded-2xl bg-slate-950/60 p-5 shadow-inner shadow-black/40 ring-1 ring-slate-800">
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="space-y-2">
<div className="text-sm font-semibold text-slate-100">
{item.title || item.encrypted_cid.slice(0, 12)}
</div>
<div className="text-xs text-slate-400">
{item.content_type} · {formatBytes(item.size.encrypted ?? 0)} enc · {formatBytes(item.size.plain ?? 0)} plain
</div>
<div className="flex flex-wrap gap-2">
{statusBadges.map((badge) => (
<Badge key={`${item.encrypted_cid}-${badge.label}`} tone={badge.toneOverride ?? toneByState(badge.value)}>
{badge.label}: {badge.value ?? '—'}
</Badge>
))}
</div>
<div className="text-xs text-slate-500">
Обновлено {formatDate(item.updated_at)} · Создано {formatDate(item.created_at)}
</div>
</div>
{actionButtons.length > 0 ? (
<div className="flex flex-wrap justify-end gap-2">
{actionButtons.map((button) => (
<a
key={`${item.encrypted_cid}-${button.label}`}
href={button.href}
target="_blank"
rel="noreferrer"
download={button.download}
className="inline-flex items-center justify-center 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"
>
{button.label}
</a>
))}
</div>
) : null}
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<div className="rounded-xl bg-slate-900/40 p-4 ring-1 ring-slate-800">
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Идентификаторы</h4>
<dl className="mt-2 space-y-1 text-xs text-slate-300">
<InfoRow label="Encrypted CID"><span className="break-all font-mono">{item.encrypted_cid}</span></InfoRow>
<InfoRow label="Metadata CID"><span className="break-all font-mono">{item.metadata_cid ?? '—'}</span></InfoRow>
<InfoRow label="Content hash"><span className="break-all font-mono">{item.content_hash ?? '—'}</span></InfoRow>
<InfoRow label="On-chain индекс">
{item.status.onchain?.indexed
? `#${item.status.onchain.onchain_index ?? '≥8'} · лицензий ${item.status.onchain.license_count ?? 0}`
: 'Не опубликован'}
</InfoRow>
<InfoRow label="On-chain адрес">
<span className="break-all font-mono">{item.status.onchain?.item_address ?? '—'}</span>
</InfoRow>
</dl>
</div>
<div className="rounded-xl bg-slate-900/40 p-4 ring-1 ring-slate-800">
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">История загрузок</h4>
{item.upload_history.length === 0 ? (
<p className="mt-2 text-xs text-slate-500">Нет событий.</p>
) : (
<ul className="mt-2 space-y-1 text-xs text-slate-300">
{item.upload_history.map((event, index) => (
<li key={`${item.encrypted_cid}-upload-${index}`} className="flex items-start justify-between gap-3">
<span className="font-semibold">
<Badge tone={toneByState(event.state)}>{event.state}</Badge>
</span>
<div className="flex-1 text-right">
<div>{formatDate(event.at)}</div>
{event.filename ? <div className="text-slate-400">{event.filename}</div> : null}
{event.error ? <div className="text-rose-300">{event.error}</div> : null}
</div>
</li>
))}
</ul>
)}
</div>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-2">
<div className="rounded-xl bg-slate-900/40 p-4 ring-1 ring-slate-800">
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Деривативы</h4>
{item.derivatives.length === 0 ? (
<p className="mt-2 text-xs text-slate-500">Деривативы отсутствуют.</p>
) : (
<div className="mt-2 space-y-2 text-xs text-slate-300">
{item.derivatives.map((derivative) => (
<div key={`${item.encrypted_cid}-${derivative.kind}`} className="rounded-lg bg-slate-950/50 p-3 ring-1 ring-slate-800">
<div className="flex items-center justify-between">
<div className="font-semibold text-slate-100">{derivative.kind}</div>
<Badge tone={toneByState(derivative.status)}>{derivative.status}</Badge>
</div>
<div className="mt-1 grid gap-1 text-xs text-slate-400 sm:grid-cols-2">
<span>Размер: {derivative.size_bytes ? formatBytes(derivative.size_bytes) : '—'}</span>
<span>Обновлено: {formatDate(derivative.updated_at)}</span>
</div>
{derivative.error ? <div className="mt-1 text-rose-300">{derivative.error}</div> : null}
{derivative.download_url ? (
<a
href={derivative.download_url}
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex items-center gap-2 text-xs font-semibold text-sky-300 hover:text-sky-200"
>
Скачать
</a>
) : null}
</div>
))}
</div>
)}
</div>
<div className="space-y-4">
<div className="rounded-xl bg-slate-900/40 p-4 ring-1 ring-slate-800">
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">IPFS</h4>
{item.ipfs ? (
<div className="mt-2 space-y-1 text-xs text-slate-300">
<div>
<Badge tone={toneByState(item.ipfs.pin_state)}>{item.ipfs.pin_state ?? '—'}</Badge>
</div>
{item.ipfs.pin_error ? <div className="text-rose-300">{item.ipfs.pin_error}</div> : null}
<div>
{formatBytes(item.ipfs.bytes_fetched ?? 0)} / {formatBytes(item.ipfs.bytes_total ?? 0)}
</div>
<div className="text-slate-500">{formatDate(item.ipfs.updated_at)}</div>
</div>
) : (
<p className="mt-2 text-xs text-slate-500">Нет информации.</p>
)}
</div>
<div className="rounded-xl bg-slate-900/40 p-4 ring-1 ring-slate-800">
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Хранение</h4>
{item.stored ? (
<div className="mt-2 space-y-1 text-xs text-slate-300">
<div>ID: {item.stored.stored_id ?? '—'}</div>
<div>Владелец: {item.stored.owner_address ?? '—'}</div>
<div>Тип: {item.stored.type ?? '—'}</div>
{item.stored.user ? (
<div className="text-slate-400">
Пользователь #{item.stored.user.id} · TG {item.stored.user.telegram_id}
</div>
) : null}
{item.stored.download_url ? (
<a
href={item.stored.download_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-2 text-xs font-semibold text-sky-300 hover:text-sky-200"
>
Скачать оригинал
</a>
) : null}
</div>
) : (
<p className="mt-2 text-xs text-slate-500">В хранилище не найден.</p>
)}
</div>
{derivativeDownloads.length > 0 ? (
<div className="rounded-xl bg-slate-900/40 p-4 ring-1 ring-slate-800">
<h4 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Прямые ссылки</h4>
<ul className="mt-2 space-y-1 text-xs text-slate-300">
{derivativeDownloads.map((entry) => (
<li key={`${item.encrypted_cid}-link-${entry.kind}`}>
<a
href={entry.url}
target="_blank"
rel="noreferrer"
className="text-sky-300 hover:text-sky-200"
>
{entry.kind} · {entry.size_bytes ? formatBytes(entry.size_bytes) : 'размер неизвестен'}
</a>
</li>
))}
</ul>
</div>
) : null}
</div>
</div>
</div>
);
};
return (
<Section
id="uploads"
title="Загрузки"
description="Статистика по состояниям и последние сессии"
description="Полная картина обработки контента: цепочка загрузки, конверсия, IPFS и публикация"
actions={
<button
type="button"
@ -636,13 +1086,133 @@ export const AdminPage = () => {
</button>
}
>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-2">
{filterOptions.map((option) => {
const isActive = option.id === uploadsFilter;
return (
<button
key={`upload-filter-${option.id}`}
type="button"
onClick={() => setUploadsFilter(option.id)}
className={clsx(
'inline-flex items-center gap-2 rounded-full border px-3 py-1 text-xs font-semibold transition',
isActive
? 'border-sky-500 bg-sky-500/20 text-sky-100 shadow-inner shadow-sky-500/20'
: 'border-slate-700 text-slate-300 hover:border-slate-500 hover:text-slate-100'
)}
>
{option.label}
<span className="rounded-full bg-slate-900/60 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-slate-300">
{numberFormatter.format(option.count)}
</span>
</button>
);
})}
{filtersActive ? (
<button
type="button"
onClick={handleResetFilters}
className="inline-flex items-center rounded-full border border-slate-700 px-3 py-1 text-xs font-semibold text-slate-300 transition hover:border-slate-500 hover:text-slate-100"
>
Сбросить
</button>
) : null}
</div>
<div className="relative w-full md:w-72">
<input
type="search"
value={uploadsSearch}
onChange={(event) => setUploadsSearch(event.target.value)}
placeholder="Поиск по названию, CID или адресу"
className="w-full rounded-lg border border-slate-700 bg-slate-950 px-3 py-2 text-sm text-slate-100 shadow-inner shadow-black/40 focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-500/40"
/>
{uploadsSearch ? (
<button
type="button"
onClick={() => setUploadsSearch('')}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-slate-800 px-2 py-1 text-[10px] font-semibold text-slate-300 transition hover:text-slate-100"
>
Очистить
</button>
) : null}
</div>
</div>
<div className="mt-2 text-xs text-slate-400">
Найдено {numberFormatter.format(filteredContents.length)} из {numberFormatter.format(totalMatches)}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<MetricCard label="Всего сессий" value={numberFormatter.format(total)} />
{Object.entries(states).map(([state, count]) => (
<MetricCard key={state} label={state} value={numberFormatter.format(count)} />
))}
</div>
<div className="overflow-x-auto">
<div className="mt-4 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<MetricCard label="Ошибки" value={numberFormatter.format(categoryCounts.issues)} />
<MetricCard label="В обработке" value={numberFormatter.format(categoryCounts.processing)} />
<MetricCard label="Готово" value={numberFormatter.format(categoryCounts.ready)} />
<MetricCard label="Без on-chain" value={numberFormatter.format(categoryCounts.unindexed)} />
</div>
{issueEntries.length > 0 ? (
<div className="mt-6 rounded-2xl border border-rose-800/40 bg-rose-900/15 p-4 shadow-inner shadow-rose-900/40">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-sm font-semibold text-rose-100">
Проблемные загрузки · {numberFormatter.format(issueEntries.length)}
</div>
<button
type="button"
onClick={() => setUploadsFilter('issues')}
className="inline-flex items-center rounded-full border border-rose-700 px-3 py-1 text-xs font-semibold text-rose-100 transition hover:border-rose-500 hover:text-rose-50"
>
Показать все ошибки
</button>
</div>
<div className="mt-3 space-y-3">
{issueEntries.slice(0, 5).map(({ item }) => {
const problems = getProblemMessages(item);
return (
<div key={`issue-${item.encrypted_cid}`} className="rounded-xl bg-rose-900/25 p-3 ring-1 ring-rose-800/50">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="text-sm font-semibold text-rose-100">{item.title || `${item.encrypted_cid.slice(0, 8)}`}</span>
<span className="text-xs text-rose-200">{formatDate(item.updated_at)}</span>
</div>
<div className="mt-1 break-all text-[11px] text-rose-200">CID: {item.encrypted_cid}</div>
<ul className="mt-2 space-y-1 text-xs text-rose-100">
{problems.map((problem, index) => (
<li key={`${item.encrypted_cid}-problem-${index}`}>{problem}</li>
))}
</ul>
</div>
);
})}
{issueEntries.length > 5 ? (
<button
type="button"
onClick={() => setUploadsFilter('issues')}
className="text-xs font-semibold text-rose-200 transition hover:text-rose-100"
>
Показать оставшиеся {numberFormatter.format(issueEntries.length - 5)}
</button>
) : null}
</div>
</div>
) : null}
<div className="mt-6 space-y-6">
{noMatches ? (
<p className="text-sm text-slate-400">
{filtersActive ? 'Нет контента по выбранным фильтрам.' : 'Контент ещё не загружался.'}
</p>
) : (
filteredContents.map(renderContentCard)
)}
</div>
<div className="mt-8 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>
@ -657,9 +1227,9 @@ export const AdminPage = () => {
{recent.map((item) => (
<tr key={item.id} className="hover:bg-slate-900/50">
<td className="px-3 py-2 font-mono text-xs text-slate-300">{item.id}</td>
<td className="px-3 py-2">{item.filename || "—"}</td>
<td className="px-3 py-2">{item.size_bytes ? formatBytes(item.size_bytes) : "—"}</td>
<td className="px-3 py-2"><Badge tone={item.state === "completed" ? "success" : item.state === "failed" ? "danger" : "neutral"}>{item.state}</Badge></td>
<td className="px-3 py-2">{item.filename || '—'}</td>
<td className="px-3 py-2">{item.size_bytes ? formatBytes(item.size_bytes) : '—'}</td>
<td className="px-3 py-2"><Badge tone={toneByState(item.state)}>{item.state}</Badge></td>
<td className="px-3 py-2">{formatDate(item.updated_at)}</td>
</tr>
))}
@ -669,7 +1239,6 @@ export const AdminPage = () => {
</Section>
);
};
const renderSystem = () => {
if (!systemQuery.data) {
return systemQuery.isLoading ? (
@ -875,7 +1444,7 @@ export const AdminPage = () => {
<Section id="status" title="Статус & лимиты" description="IPFS, пины и лимиты">Загрузка</Section>
) : null;
}
const { ipfs, pin_counts, derivatives, convert_backlog, limits } = statusQuery.data;
const { ipfs, pin_counts, derivatives, convert_backlog } = statusQuery.data;
return (
<Section id="status" title="Статус & лимиты" description="Информация о синхронизации, кэше и лимитах">
<div className="grid gap-6 xl:grid-cols-[1.8fr_1fr]">

View File

@ -10,6 +10,7 @@ import { AudioPlayer } from '~/shared/ui/audio-player';
import { useAuth } from '~/shared/services/auth';
import { CongratsModal } from './components/congrats-modal';
import { ErrorModal } from './components/error-modal';
import { resolveStartPayload } from '~/shared/utils/start-payload';
type InvoiceStatus = 'paid' | 'failed' | 'cancelled' | 'pending';
@ -21,9 +22,10 @@ interface InvoiceEvent {
export const ViewContentPage = () => {
const WebApp = useWebApp();
const { contentId } = resolveStartPayload();
const { data: content, refetch: refetchContent } = useViewContent(
WebApp.initDataUnsafe?.start_param
contentId
);
const { mutateAsync: purchaseContent } = usePurchaseContent();
@ -34,7 +36,32 @@ export const ViewContentPage = () => {
const [isCongratsModal, setIsCongratsModal] = useState(false);
const [isErrorModal, setIsErrorModal] = useState(false);
const statusState = content?.data?.status?.state ?? "uploaded";
const conversionState = content?.data?.conversion?.state;
const uploadState = content?.data?.upload?.state;
const statusLabel = useMemo(() => {
switch (statusState) {
case "ready":
return "Готово";
case "processing":
return "Обрабатывается";
case "failed":
return "Ошибка";
default:
return "Загружено";
}
}, [statusState]);
const mediaUrl = content?.data?.display_options?.content_url ?? null;
const isAudio = Boolean(content?.data?.content_type?.startsWith('audio'));
const isReady = Boolean(mediaUrl);
const metadataName = content?.data?.display_options?.metadata?.name;
const handleBuyContentTON = useCallback(async () => {
if (!contentId) {
console.error('No content identifier available for purchase');
return;
}
try {
// Если не подключен, начинаем процесс подключения через auth
if (!tonConnectUI.connected) {
@ -79,7 +106,7 @@ export const ViewContentPage = () => {
// Теперь продолжаем с покупкой
console.log('DEBUG: Proceeding with purchase');
const contentResponse = await purchaseContent({
content_address: WebApp.initDataUnsafe?.start_param,
content_address: contentId,
license_type: 'resale',
});
@ -106,7 +133,7 @@ export const ViewContentPage = () => {
setIsErrorModal(true);
console.error('Error handling Ton Connect subscription:', error);
}
}, [auth, purchaseContent, refetchContent, tonConnectUI, WebApp.initDataUnsafe?.start_param]);
}, [auth, contentId, purchaseContent, refetchContent, tonConnectUI]);
const handleBuyContentStars = useCallback(async () => {
try {
@ -157,12 +184,16 @@ export const ViewContentPage = () => {
}, [content]);
useEffect(() => {
if (!contentId) {
return () => undefined;
}
const interval = setInterval(() => {
void refetchContent();
}, 5000);
return () => clearInterval(interval);
}, []);
}, [contentId, refetchContent]);
const handleConfirmCongrats = () => {
setIsCongratsModal(!isCongratsModal);
@ -190,7 +221,7 @@ export const ViewContentPage = () => {
<main className={'min-h-screen flex w-full flex-col gap-[50px] px-4 '}>
{isCongratsModal && <CongratsModal onConfirm={handleConfirmCongrats} />}
{isErrorModal && <ErrorModal onConfirm={handleErrorModal} />}
{content?.data?.content_type.startsWith('audio') &&
{isAudio &&
content?.data?.display_options?.metadata?.image && (
<div className={'mt-[30px] h-[314px] w-full'}>
<img
@ -201,31 +232,45 @@ export const ViewContentPage = () => {
</div>
)}
{content?.data?.content_type.startsWith('audio') ? (
<AudioPlayer src={content?.data?.display_options?.content_url} />
) : (
<ReactPlayer
playsinline={true}
controls={true}
width="100%"
config={{
file: {
attributes: {
playsInline: true,
autoPlay: true,
poster:
content?.data?.display_options?.metadata?.image || undefined,
{isReady ? (
isAudio ? (
<AudioPlayer src={mediaUrl ?? ''} />
) : (
<ReactPlayer
playsinline={true}
controls={true}
width="100%"
config={{
file: {
attributes: {
playsInline: true,
autoPlay: true,
poster: content?.data?.display_options?.metadata?.image || undefined,
},
},
},
}}
url={content?.data?.display_options?.content_url}
/>
}}
url={mediaUrl}
/>
)
) : (
<div className={'rounded-xl bg-slate-900/60 p-4 text-center text-sm text-slate-200'}>
<p>{statusLabel}. Конвертация может занять несколько минут.</p>
{conversionState && (
<p className={'mt-2 text-xs text-slate-400'}>
Статус конвертера: {conversionState}
</p>
)}
{uploadState && (
<p className={'mt-1 text-xs text-slate-400'}>
Загрузка: {uploadState}
</p>
)}
</div>
)}
<section className={'flex flex-col'}>
<h1 className={'text-[20px] font-bold'}>
{content?.data?.display_options?.metadata?.name}
</h1>
<h1 className={'text-[20px] font-bold'}>{metadataName}</h1>
<span className={'mt-1 text-xs text-slate-400'}>{statusLabel}</span>
{/*<h2>Russian</h2>*/}
{/*<h2>2022</h2>*/}
<p className={'mt-2 text-[12px]'}>

View File

@ -78,6 +78,106 @@ export type AdminStorageResponse = {
};
};
export type AdminUploadsContentDerivative = {
kind: string;
status: string;
size_bytes: number | null;
error: string | null;
created_at: string | null;
updated_at: string | null;
attempts: number;
download_url: string | null;
};
export type AdminUploadsContentUploadHistoryItem = {
state: string;
at: string | null;
error: string | null;
filename: string | null;
};
export type AdminUploadsContentIpfs = {
pin_state: string | null;
pin_error: string | null;
bytes_total: number | null;
bytes_fetched: number | null;
pinned_at: string | null;
updated_at: string | null;
} | null;
export type AdminUploadsContentStored = {
stored_id: number | null;
type: string | null;
owner_address: string | null;
user_id: number | null;
status: string | null;
content_url: string | null;
download_url: string | null;
created: string | null;
updated: string | null;
user?: {
id: number;
telegram_id: number;
username: string | null;
first_name: string | null;
last_name: string | null;
};
} | null;
export type AdminUploadsContentLinks = {
web_view: string | null;
start_app: string | null;
api_view: string | null;
download_primary: string | null;
download_derivatives: Array<{
kind: string;
url: string;
size_bytes: number | null;
}>;
};
export type AdminUploadsContentFlags = {
issues: boolean;
processing: boolean;
ready: boolean;
unindexed: boolean;
};
export type AdminUploadsContentStatus = {
upload_state: string | null;
conversion_state: string | null;
ipfs_state: string | null;
onchain: {
indexed: boolean;
onchain_index: number | null;
item_address: string | null;
license_count?: number;
} | null;
};
export type AdminUploadsContent = {
encrypted_cid: string;
metadata_cid: string | null;
content_hash: string | null;
title: string;
description: string | null;
content_type: string;
size: {
encrypted: number | null;
plain: number | null;
};
created_at: string | null;
updated_at: string | null;
status: AdminUploadsContentStatus;
upload_history: AdminUploadsContentUploadHistoryItem[];
derivative_summary: Record<string, number>;
derivatives: AdminUploadsContentDerivative[];
ipfs: AdminUploadsContentIpfs;
stored: AdminUploadsContentStored;
links: AdminUploadsContentLinks;
flags?: AdminUploadsContentFlags;
};
export type AdminUploadsResponse = {
total: number;
states: Record<string, number>;
@ -91,8 +191,25 @@ export type AdminUploadsResponse = {
updated_at: string;
created_at: string;
}>;
contents: AdminUploadsContent[];
matching_total?: number;
filter?: string[];
search?: string | null;
limit?: number;
scan?: number;
scanned?: number;
category_totals?: Record<string, number>;
};
export type AdminUploadsQueryParams = {
filter?: string | string[];
search?: string;
limit?: number;
scan?: number;
};
type AdminUploadsQueryKey = ['admin', 'uploads', string | null, string | null, number | null, number | null];
export type AdminSystemResponse = {
env: Record<string, string | null | undefined>;
service_config: Array<{
@ -232,12 +349,34 @@ export const useAdminStorage = (
};
export const useAdminUploads = (
options?: QueryOptions<AdminUploadsResponse, ['admin', 'uploads']>,
params?: AdminUploadsQueryParams,
options?: QueryOptions<AdminUploadsResponse, AdminUploadsQueryKey>,
) => {
return useQuery<AdminUploadsResponse, AxiosError, AdminUploadsResponse, ['admin', 'uploads']>(
['admin', 'uploads'],
const filterParts = Array.isArray(params?.filter)
? params.filter.filter(Boolean)
: params?.filter
? [params.filter]
: [];
const filterValue = filterParts.length > 0 ? filterParts.join(',') : null;
const searchValue = params?.search?.trim() || null;
const limitValue = params?.limit ?? null;
const scanValue = params?.scan ?? null;
const queryKey: AdminUploadsQueryKey = ['admin', 'uploads', filterValue, searchValue, limitValue, scanValue];
const queryParams: Record<string, string | number | undefined> = {
filter: filterValue ?? undefined,
search: searchValue ?? undefined,
limit: limitValue ?? undefined,
scan: scanValue ?? undefined,
};
return useQuery<AdminUploadsResponse, AxiosError, AdminUploadsResponse, AdminUploadsQueryKey>(
queryKey,
async () => {
const { data } = await request.get<AdminUploadsResponse>('/admin.uploads');
const { data } = await request.get<AdminUploadsResponse>('/admin.uploads', {
params: queryParams,
});
return data;
},
{

View File

@ -3,6 +3,7 @@ import { useTonConnectUI } from '@tonconnect/ui-react';
import { useMutation } from 'react-query';
import { request } from '~/shared/libs';
import { useWebApp } from '@vkruglikov/react-telegram-web-app';
import { appendReferral } from '~/shared/utils/start-payload';
const sessionStorageKey = 'auth_v1_token';
const tonProofStorageKey = 'stored_ton_proof';
@ -47,7 +48,7 @@ export const useAuth = () => {
ton_balance: string;
};
auth_v1_token: string;
}>('/auth.twa', params);
}>('/auth.twa', appendReferral(params));
if (res?.data?.auth_v1_token) {
localStorage.setItem(sessionStorageKey, res.data.auth_v1_token);
@ -95,9 +96,9 @@ export const useAuth = () => {
try {
// Get the payload/token from backend
const value = await request.post<{ auth_v1_token: string }>('/auth.twa', {
const value = await request.post<{ auth_v1_token: string }>('/auth.twa', appendReferral({
twa_data: WebApp.initData,
});
}));
if (value?.data?.auth_v1_token) {
console.log('DEBUG: Got token for connect params');

View File

@ -1,6 +1,7 @@
import { useMutation } from "react-query";
import { request } from "~/shared/libs";
import { useWebApp } from "@vkruglikov/react-telegram-web-app";
import { appendReferral } from "~/shared/utils/start-payload";
const sessionStorageKey = "auth_v1_token";
@ -10,9 +11,9 @@ export const useAuthTwa = () => {
const makeAuthRequest = async () => {
const res = await request.post<{
auth_v1_token: string;
}>("/auth.twa", {
}>("/auth.twa", appendReferral({
twa_data: WebApp.initData,
});
}));
if (res?.data?.auth_v1_token) {
localStorage.setItem(sessionStorageKey, res.data.auth_v1_token);

View File

@ -45,14 +45,20 @@ export const useCreateNewContent = () => {
// );
// };
export const useViewContent = (contentId: string) => {
return useQuery(["view", "content", contentId], () => {
return request.get(`/content.view/${contentId}`, {
headers: {
Authorization: localStorage.getItem('auth_v1_token') ?? ""
}
});
});
export const useViewContent = (contentId: string | null | undefined) => {
return useQuery(
["view", "content", contentId],
() => {
return request.get(`/content.view/${contentId}`, {
headers: {
Authorization: localStorage.getItem('auth_v1_token') ?? ""
}
});
},
{
enabled: Boolean(contentId),
}
);
};
export const usePurchaseContent = () => {