import { useMemo, useState } from "react"; import clsx from "clsx"; import { useAdminUploads, type AdminUploadsContent, type AdminUploadsContentFlags, } from "~/shared/services/admin"; import { Section, Badge, InfoRow, CopyButton } from "../components"; import { useAdminContext } from "../context"; import { formatBytes, formatDate, numberFormatter } from "../utils/format"; type UploadCategory = "issues" | "processing" | "ready" | "unindexed"; type UploadFilter = "all" | UploadCategory; type UploadDecoration = { item: AdminUploadsContent; categories: Set; searchText: string; hasIssue: boolean; isReady: boolean; isProcessing: boolean; isUnindexed: boolean; flags: AdminUploadsContentFlags | null; }; const numberCompact = new Intl.NumberFormat("ru-RU", { notation: "compact" }); 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(); 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 = [ 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 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}`); } return messages; }; const UploadCard = ({ decoration }: { decoration: UploadDecoration }) => { const { item, flags } = decoration; const problemMessages = decoration.hasIssue ? getProblemMessages(item) : []; const derivativeDownloads = item.links.download_derivatives ?? []; return (

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

{item.description || "Описание не задано"}

{flags?.ready ? готово : null} {flags?.issues ? проблемы : null} {flags?.processing ? в обработке : null} {flags?.unindexed ? без индекса : null}
{formatDate(item.created_at)} {formatDate(item.updated_at)} {item.size.plain ? {formatBytes(item.size.plain)} : null}

On-chain

{item.encrypted_cid} {item.metadata_cid ?? "—"} {item.content_hash ?? "—"} {item.status.onchain?.indexed ? ( indexed {item.status.onchain?.onchain_index ?? "—"} ) : ( не опубликовано )} {item.status.onchain?.item_address ?? "—"}

Статусы

Загрузка

{item.status.upload_state ?? "—"}

Конверсия

{item.status.conversion_state ?? "—"}

IPFS

{item.status.ipfs_state ?? "—"}

История загрузки

    {item.upload_history.map((event) => (
  • {event.state} {formatDate(event.at)}
    {event.error ?

    {event.error}

    : null} {event.filename ?

    {event.filename}

    : null}
  • ))}
{problemMessages.length > 0 ? (

Проблемы

    {problemMessages.map((message, index) => (
  • {message}
  • ))}
) : null}

Деривативы

    {item.derivatives.map((derivative) => (
  • {derivative.kind} {derivative.status}
    Размер: {formatBytes(derivative.size_bytes)}
    Попытки: {derivative.attempts}
    Создан: {formatDate(derivative.created_at)}
    Обновлён: {formatDate(derivative.updated_at)}
    {derivative.error ?
    {derivative.error}
    : null} {derivative.download_url ? ( Скачать ) : null}
  • ))}

IPFS

{item.ipfs ? (
Статус: {item.ipfs.pin_state ?? "—"}
Ошибка: {item.ipfs.pin_error ?? "—"}
Fetched: {formatBytes(item.ipfs.bytes_fetched)}
Total: {formatBytes(item.ipfs.bytes_total)}
{formatDate(item.ipfs.updated_at)}
) : (

Нет информации.

)}

Хранение

{item.stored ? (
ID: {item.stored.stored_id ?? "—"} {item.stored.stored_id ? ( ) : null}
Владелец: {item.stored.owner_address ?? "—"} {item.stored.owner_address ? ( ) : null}
Тип: {item.stored.type ?? "—"}
{item.stored.user ? (
Пользователь #{item.stored.user.id} · TG {item.stored.user.telegram_id}
) : null} {item.stored.download_url ? ( Скачать оригинал ) : null}
) : (

В хранилище не найден.

)}
{derivativeDownloads.length > 0 ? ( ) : null}
); }; export const AdminUploadsPage = () => { const { isAuthorized, handleRequestError } = useAdminContext(); const [uploadsFilter, setUploadsFilter] = useState("all"); const [uploadsSearch, setUploadsSearch] = useState(""); const normalizedUploadsSearch = uploadsSearch.trim(); const hasUploadsSearch = normalizedUploadsSearch.length > 0; const isUploadsFilterActive = uploadsFilter !== "all"; const uploadsScanLimit = isUploadsFilterActive || hasUploadsSearch ? 200 : 100; const uploadsQuery = useAdminUploads( { filter: isUploadsFilterActive ? uploadsFilter : undefined, search: hasUploadsSearch ? normalizedUploadsSearch : undefined, limit: 40, scan: uploadsScanLimit, }, { enabled: isAuthorized, keepPreviousData: true, refetchInterval: 30_000, onError: (error) => handleRequestError(error, "Не удалось загрузить список загрузок"), }, ); const uploadContents = uploadsQuery.data?.contents ?? []; const decoratedContents = useMemo(() => uploadContents.map((item) => classifyUpload(item)), [uploadContents]); if (uploadsQuery.isLoading && !uploadsQuery.data) { return (
Загрузка…
); } if (!uploadsQuery.data) { return null; } const { states, total, recent, matching_total: matchingTotalRaw, category_totals: categoryTotalsRaw } = uploadsQuery.data; const categoryTotals = categoryTotalsRaw ?? {}; const categoryCounts: Record = { issues: categoryTotals.issues ?? 0, processing: categoryTotals.processing ?? 0, ready: categoryTotals.ready ?? 0, unindexed: categoryTotals.unindexed ?? 0, }; 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")).slice(0, 3); 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(""); }; return (
uploadsQuery.refetch()} disabled={uploadsQuery.isFetching} > {uploadsQuery.isFetching ? "Обновляем…" : "Обновить"} } >
{Object.entries(states ?? {}).map(([key, value]) => (

{key}

{numberFormatter.format(value)}

))}

Всего

{numberFormatter.format(total)}

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

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

{numberCompact.format(categoryCounts.processing)}

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

{filterOptions.map((option) => { const isActive = option.id === uploadsFilter; return ( ); })} {filtersActive ? ( ) : null}
setUploadsSearch(event.target.value)} placeholder="Название, CID, пользователь…" 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" />
{issueEntries.length > 0 ? (

Последние проблемы

    {issueEntries.map((entry) => (
  • {entry.item.title || entry.item.encrypted_cid} {formatDate(entry.item.updated_at)}
  • ))}
) : null} {noMatches ? (
Под подходящие условия не найдено загрузок.
) : (
{filteredEntries.map((entry) => ( ))}
)}

Последние события

{recent.map((task) => ( ))}
ID Файл Размер Состояние CID Ошибка Обновлено
{task.id} {task.filename ?? "—"} {formatBytes(task.size_bytes)} {task.state} {task.encrypted_cid ?? "—"} {task.error ?? "—"} {formatDate(task.updated_at)}
); };