improve system

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

No files matched your search

+587 -18
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]">