web2-client/src/pages/admin/sections/Uploads.tsx

583 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useMemo, useState } from "react";
import clsx from "clsx";
import {
useAdminUploads,
type AdminUploadsContent,
type AdminUploadsContentFlags,
} from "~/shared/services/admin";
import { Section, Badge, InfoRow } 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<UploadCategory>;
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<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 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 (
<div className="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>
<h3 className="text-lg font-semibold text-slate-100">{item.title || "Без названия"}</h3>
<p className="text-xs text-slate-400">{item.description || "Описание не задано"}</p>
<div className="mt-2 flex flex-wrap items-center gap-2 text-[11px] uppercase tracking-wide text-slate-500">
{flags?.ready ? <Badge tone="success">готово</Badge> : null}
{flags?.issues ? <Badge tone="danger">проблемы</Badge> : null}
{flags?.processing ? <Badge tone="warn">в обработке</Badge> : null}
{flags?.unindexed ? <Badge tone="neutral">без индекса</Badge> : null}
</div>
</div>
<div className="flex flex-col items-end gap-1 text-xs text-slate-400">
<span>{formatDate(item.created_at)}</span>
<span>{formatDate(item.updated_at)}</span>
{item.size.plain ? <span>{formatBytes(item.size.plain)}</span> : null}
</div>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-[1.3fr_1fr]">
<div className="space-y-4">
<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>
<div className="mt-2 grid gap-2 text-xs text-slate-300 sm:grid-cols-2">
<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 ? (
<span className="inline-flex items-center gap-2">
<Badge tone="success">indexed</Badge>
{item.status.onchain?.onchain_index ?? "—"}
</span>
) : (
<Badge tone="warn">не опубликовано</Badge>
)}
</InfoRow>
<InfoRow label="On-chain адрес">
<span className="break-all font-mono text-xs">{item.status.onchain?.item_address ?? "—"}</span>
</InfoRow>
</div>
</div>
<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">Статусы</h4>
<div className="mt-2 grid gap-3 text-xs text-slate-300 md:grid-cols-2">
<div>
<p className="font-semibold text-slate-200">Загрузка</p>
<p>{item.status.upload_state ?? "—"}</p>
</div>
<div>
<p className="font-semibold text-slate-200">Конверсия</p>
<p>{item.status.conversion_state ?? "—"}</p>
</div>
<div>
<p className="font-semibold text-slate-200">IPFS</p>
<p>{item.status.ipfs_state ?? "—"}</p>
</div>
</div>
</div>
<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">История загрузки</h4>
<ul className="mt-2 space-y-2 text-xs text-slate-300">
{item.upload_history.map((event) => (
<li key={`${item.encrypted_cid}-${event.filename}-${event.state}`} className="rounded-lg border border-slate-800 bg-slate-950/40 px-3 py-2">
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-semibold text-slate-200">{event.state}</span>
<span className="text-[11px] text-slate-500">{formatDate(event.at)}</span>
</div>
{event.error ? <p className="mt-1 text-rose-300">{event.error}</p> : null}
{event.filename ? <p className="mt-1 text-slate-400">{event.filename}</p> : null}
</li>
))}
</ul>
</div>
{problemMessages.length > 0 ? (
<div className="rounded-xl border border-rose-500/40 bg-rose-500/10 p-4 text-xs text-rose-100">
<h4 className="font-semibold uppercase tracking-wide">Проблемы</h4>
<ul className="mt-2 space-y-1">
{problemMessages.map((message, index) => (
<li key={`${item.encrypted_cid}-issue-${index}`}>{message}</li>
))}
</ul>
</div>
) : null}
</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">Деривативы</h4>
<ul className="mt-2 space-y-2 text-xs text-slate-300">
{item.derivatives.map((derivative) => (
<li key={`${item.encrypted_cid}-${derivative.kind}`} className="rounded-lg border border-slate-800 bg-slate-950/40 px-3 py-2">
<div className="flex items-center justify-between">
<span className="font-semibold text-slate-200">{derivative.kind}</span>
<Badge tone={derivative.status === "ready" ? "success" : derivative.status === "failed" ? "danger" : "warn"}>
{derivative.status}
</Badge>
</div>
<div className="mt-1 space-y-1 text-[11px] text-slate-400">
<div>Размер: {formatBytes(derivative.size_bytes)}</div>
<div>Попытки: {derivative.attempts}</div>
<div>Создан: {formatDate(derivative.created_at)}</div>
<div>Обновлён: {formatDate(derivative.updated_at)}</div>
{derivative.error ? <div className="text-rose-300">{derivative.error}</div> : null}
{derivative.download_url ? (
<a
href={derivative.download_url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center text-sky-300 hover:text-sky-200"
>
Скачать
</a>
) : null}
</div>
</li>
))}
</ul>
</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">IPFS</h4>
{item.ipfs ? (
<div className="mt-2 text-xs text-slate-300">
<div>Статус: {item.ipfs.pin_state ?? "—"}</div>
<div>Ошибка: {item.ipfs.pin_error ?? "—"}</div>
<div>Fetched: {formatBytes(item.ipfs.bytes_fetched)}</div>
<div>Total: {formatBytes(item.ipfs.bytes_total)}</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>
);
};
export const AdminUploadsPage = () => {
const { isAuthorized, handleRequestError } = useAdminContext();
const [uploadsFilter, setUploadsFilter] = useState<UploadFilter>("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 (
<Section id="uploads" title="Загрузки" description="Мониторинг загрузок и конвертации">
Загрузка
</Section>
);
}
if (!uploadsQuery.data) {
return null;
}
const { states, total, recent, 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 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 (
<Section
id="uploads"
title="Загрузки"
description="Полная картина обработки контента: цепочка загрузки, конверсия, IPFS и публикация"
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={() => uploadsQuery.refetch()}
disabled={uploadsQuery.isFetching}
>
{uploadsQuery.isFetching ? "Обновляем…" : "Обновить"}
</button>
}
>
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
{Object.entries(states ?? {}).map(([key, value]) => (
<div key={key} className="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="mt-2 text-lg font-semibold text-slate-100">{numberFormatter.format(value)}</p>
</div>
))}
<div className="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="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>
</div>
<div className="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="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>
</div>
</div>
<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-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"
/>
</div>
</div>
{issueEntries.length > 0 ? (
<div className="rounded-xl border border-amber-500/40 bg-amber-500/10 p-4 text-xs text-amber-100">
<h3 className="text-sm font-semibold text-amber-50">Последние проблемы</h3>
<ul className="mt-2 space-y-1">
{issueEntries.map((entry) => (
<li key={`issue-${entry.item.encrypted_cid}`} className="flex items-center justify-between gap-2">
<span className="truncate text-sm text-amber-50">{entry.item.title || entry.item.encrypted_cid}</span>
<span className="text-[11px] text-amber-200">{formatDate(entry.item.updated_at)}</span>
</li>
))}
</ul>
</div>
) : null}
{noMatches ? (
<div className="rounded-xl border border-slate-800 bg-slate-950/40 p-6 text-sm text-slate-400">
Под подходящие условия не найдено загрузок.
</div>
) : (
<div className="grid gap-5">
{filteredEntries.map((entry) => (
<UploadCard key={entry.item.encrypted_cid} decoration={entry} />
))}
</div>
)}
<div className="rounded-xl bg-slate-950/40 p-4 ring-1 ring-slate-800">
<h3 className="text-sm font-semibold uppercase tracking-wide text-slate-400">Последние события</h3>
<div className="mt-2 overflow-x-auto">
<table className="min-w-full divide-y divide-slate-800 text-left text-xs">
<thead className="bg-slate-950/70 text-[11px] uppercase tracking-wide text-slate-400">
<tr>
<th className="px-3 py-2">ID</th>
<th className="px-3 py-2">Файл</th>
<th className="px-3 py-2">Размер</th>
<th className="px-3 py-2">Состояние</th>
<th className="px-3 py-2">CID</th>
<th className="px-3 py-2">Ошибка</th>
<th className="px-3 py-2">Обновлено</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-900/60">
{recent.map((task) => (
<tr key={task.id} className="hover:bg-slate-900/50">
<td className="px-3 py-2 font-mono text-[10px] text-slate-400">{task.id}</td>
<td className="px-3 py-2">{task.filename ?? "—"}</td>
<td className="px-3 py-2">{formatBytes(task.size_bytes)}</td>
<td className="px-3 py-2">
<Badge tone={task.state === "done" ? "success" : task.state === "failed" ? "danger" : "warn"}>
{task.state}
</Badge>
</td>
<td className="px-3 py-2 font-mono text-[10px] text-slate-500">{task.encrypted_cid ?? "—"}</td>
<td className="px-3 py-2 text-rose-200">{task.error ?? "—"}</td>
<td className="px-3 py-2 text-slate-400">{formatDate(task.updated_at)}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
</Section>
);
};