Frontend Feature Changes

This commit is contained in:
sriram
2026-09-02 12:27:10 +05:30
parent b677ddf9e6
commit 2108e402b0
7 changed files with 869 additions and 513 deletions

View File

@@ -0,0 +1,670 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
AlertTriangle, Ban, CheckCircle2, ClipboardCheck, Clock, FileSpreadsheet,
Loader2, PlayCircle, RefreshCw, XCircle, ArrowRight,
} from 'lucide-react';
import { api } from '../api/client';
import { STAGE_FLAGS, ago, fmtBytes } from './pipelineFormat';
import { FileStages, Stat } from './pipelineShared';
/*
* Admin -> Upload Results.
*
* What happened to the last file a colleague sent. No picking, no clicking:
* the newest colleague upload is found and followed automatically, all eleven
* stages, down to which rows the validation gate refused and why.
*
* THIS TAB USED TO BE THE REVIEW INBOX, and the inbox is still here - see the
* gate section below. It is just no longer the point of the screen, because
* with UPLOAD_AUTORUN at its default of true a colleague's upload starts on
* arrival and never waits for review, so the inbox is permanently empty and the
* tab showed nothing but an empty state pointing at another tab.
*
* HOW THIS DIFFERS FROM DAGSTER ORCHESTRATION, which also draws stage
* timelines: that screen is for a run YOU launched - you tick files and watch
* the batch you started, including your own admin uploads. This one answers the
* opposite question, about a run nobody here started, and it is the only place
* that shows the per-row rejection reasons a sender needs to fix their sheet.
*
* WHICH RUN IS "THE COLLEAGUE'S". `submitted_by` is the discriminator and it is
* exact, not a heuristic: POST /api/uploads/catalog always sets it (falling back
* to the `sender` form label and then to "anonymous"), and the admin's own
* POST /api/admin/catalog-batch/ingest never passes it at all, so it is null
* there. A batch released from the inbox carries the original senders, which is
* correct - it is still their upload, just started by hand.
*/
// Two questions at two cadences. "Has anything new arrived?" is cheap and slow;
// "how far has the run I am watching got?" is the one that needs to feel live,
// and it stops itself at a terminal status so a tab left open overnight goes
// quiet. Both are cleared on unmount.
const DISCOVER_POLL_MS = 10000;
const RUN_POLL_MS = 3000;
// How far back to look for a colleague upload. The endpoint returns ALL runs
// newest-first, admin batches included, so a window of 20 could be filled by
// the admin's own uploads and hide the colleague's. Slim rows are not tiny -
// they keep stages[], rejections and errors - so ask for 50 on the steady-state
// tick and widen to the endpoint's ceiling only when nothing was found.
const DISCOVER_LIMIT = 50;
const DISCOVER_MAX_LIMIT = 100;
// When to stop following a run. Wider than the Orchestration tab's set
// (OrchestrationPanel.jsx), which omits both `retired` and `interrupted`.
//
// `interrupted` is not terminal on the backend - a Resume can still move it -
// but nothing will move it on its own, so a 3s poll would hammer a batch that
// cannot change until an operator acts. Park on it; the 10s discovery tick
// notices if BATCH_AUTO_RESUME re-queues it after a restart.
const SETTLED = new Set([
'done', 'failed', 'partial', 'cancelled', 'retired', 'interrupted',
]);
const STATUS_ICON = {
done: CheckCircle2,
partial: AlertTriangle,
failed: XCircle,
running: Loader2,
interrupted: AlertTriangle,
};
const STATUS_TONE = {
done: 'text-leaf-600',
partial: 'text-amber-600',
failed: 'text-maroon-600',
running: 'text-amber-600',
interrupted: 'text-amber-600',
};
/* Is this row a colleague's upload, and does it have anything to show?
*
* `submitted_by` alone is not enough. GET /batches drops only PENDING rows
* (batch_catalog.py), so a drop that was dismissed outright survives the filter
* as a RETIRED husk - it still carries the sender's name, but every file was
* discarded, so it has no result and no stage timeline. Without the second
* clause, dismissing a drop makes that husk "the latest upload" and the panel
* renders eleven grey rows that will never fill in.
*
* A drop that was RELEASED rather than dismissed is harmless either way: the
* run it produced is staged before the drop is retired, so the run is newer and
* wins the ordering regardless. */
function isColleagueUpload(batch) {
return (
typeof batch?.submitted_by === 'string' &&
batch.submitted_by.trim() !== '' &&
batch.status !== 'retired'
);
}
function IssueList({ title, items, tone = 'slate' }) {
if (!items.length) return null;
const border =
tone === 'maroon' ? 'border-maroon-500/25 bg-maroon-100/30' : 'border-ink-900/10 bg-white';
return (
<div className={`mt-4 rounded-xl border p-3 ${border}`}>
<p className="text-[10px] font-bold uppercase tracking-wide text-slate-500 mb-1.5">
{title} <span className="font-mono opacity-60">({items.length})</span>
</p>
<ul className="space-y-1">
{items.map((text, i) => (
<li key={i} className="text-[11px] leading-relaxed text-slate-600">
{text}
</li>
))}
</ul>
</div>
);
}
export function UploadResultsPanel({ onBatchStarted }) {
// --- the results half ---------------------------------------------------
const [run, setRun] = useState(null);
const [discovered, setDiscovered] = useState(false); // first discovery done
const runIdRef = useRef(null);
// Whether the widened lookup has already been spent - see discover().
const escalatedRef = useRef(false);
// --- the gate half (only rendered when something is actually waiting) ----
const [inbox, setInbox] = useState(null);
const [selected, setSelected] = useState(() => new Set());
const [busy, setBusy] = useState('');
const [error, setError] = useState('');
const [notice, setNotice] = useState('');
const [useLlm, setUseLlm] = useState(true);
const [fetchImages, setFetchImages] = useState(true);
/* Find the newest colleague upload, and read the inbox in the same tick.
*
* A failed poll is swallowed rather than replacing the panel with an error:
* a transient blip must not wipe a run the admin is reading, and the next
* tick retries. Same reasoning as the badge poll in AdminPage. */
const discover = useCallback(async () => {
try {
let { batches } = await api.listCatalogBatches(DISCOVER_LIMIT);
let latest = (batches || []).find(isColleagueUpload);
// Nothing in the window. Before concluding there is no colleague upload,
// widen once to the endpoint's ceiling - a burst of admin batches can push
// a real one past 50. Guarded so it happens at most once per mount (and
// once per manual Refresh), never on the steady-state path where a run is
// already on screen.
if (!latest && !escalatedRef.current) {
escalatedRef.current = true;
({ batches } = await api.listCatalogBatches(DISCOVER_MAX_LIMIT));
latest = (batches || []).find(isColleagueUpload);
}
if (latest && latest.batch_id !== runIdRef.current) {
runIdRef.current = latest.batch_id;
// Show the slim row immediately - it already carries stages[] and the
// counters, because slim strips only result.products - then let the
// detail poll replace it with the full record.
setRun(latest);
} else if (!latest) {
runIdRef.current = null;
setRun(null);
}
} catch {
/* next tick retries */
}
try {
setInbox(await api.listInbox());
} catch {
/* next tick retries */
}
setDiscovered(true);
}, []);
useEffect(() => {
discover();
const timer = setInterval(discover, DISCOVER_POLL_MS);
return () => clearInterval(timer);
}, [discover]);
// Follow the watched run until it settles. Re-created whenever the id or the
// status changes, so it stops itself the moment the run settles rather than
// polling a finished batch forever.
const runId = run?.batch_id;
const runStatus = run?.status;
useEffect(() => {
if (!runId || SETTLED.has(runStatus)) return undefined;
let alive = true;
const tick = async () => {
try {
const full = await api.getCatalogBatch(runId);
if (alive && full?.batch_id === runIdRef.current) setRun(full);
} catch {
/* next tick retries */
}
};
tick();
const timer = setInterval(tick, RUN_POLL_MS);
return () => {
alive = false;
clearInterval(timer);
};
}, [runId, runStatus]);
// A run that was already settled when we adopted it never enters the poll
// above, so fetch its full record once. A run that settles while being
// followed already had its last fetch from that poll.
useEffect(() => {
if (!runId || !SETTLED.has(runStatus)) return undefined;
let alive = true;
(async () => {
try {
const full = await api.getCatalogBatch(runId);
if (alive && full?.batch_id === runIdRef.current) setRun(full);
} catch {
/* the slim row is already on screen; it is enough */
}
})();
return () => {
alive = false;
};
// Deliberately keyed on the id alone: re-running this on every status
// change would re-fetch a settled batch for no reason.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [runId]);
// --- gate actions (unchanged behaviour, just conditionally rendered) -----
const toggle = (fileId) => {
setSelected((current) => {
const next = new Set(current);
if (next.has(fileId)) next.delete(fileId);
else next.add(fileId);
return next;
});
};
const toggleDrop = (submission) => {
const ids = submission.files.map((f) => f.file_id);
const allOn = ids.every((id) => selected.has(id));
setSelected((current) => {
const next = new Set(current);
ids.forEach((id) => (allOn ? next.delete(id) : next.add(id)));
return next;
});
};
const handleStart = async () => {
if (!selected.size) return;
setBusy('start');
setError('');
setNotice('');
try {
const batch = await api.startBatchFromInbox([...selected], {
use_llm: useLlm,
fetch_images: fetchImages,
});
setSelected(new Set());
setNotice(
`Started ${batch.files_total} file(s) as one batch. Follow it on the ` +
`Batch Catalog Ingestion tab.`
);
await discover();
if (onBatchStarted) onBatchStarted(batch);
} catch (err) {
setError(err?.message || 'Could not start those files.');
await discover();
} finally {
setBusy('');
}
};
const handleDismiss = async () => {
if (!selected.size) return;
setBusy('dismiss');
setError('');
setNotice('');
try {
const result = await api.dismissInboxFiles([...selected]);
setSelected(new Set());
setNotice(`Dismissed ${result.dismissed} file(s). They will not be processed.`);
await discover();
} catch (err) {
setError(err?.message || 'Could not dismiss those files.');
await discover();
} finally {
setBusy('');
}
};
// Drop any selection that is no longer pending - another tab may have started
// or dismissed it, and a checkbox pointing at a file that is already gone
// would 409 on the next click with no explanation.
useEffect(() => {
if (!inbox) return;
const live = new Set(inbox.submissions.flatMap((s) => s.files.map((f) => f.file_id)));
setSelected((current) => new Set([...current].filter((id) => live.has(id))));
}, [inbox]);
const pending = inbox?.pending_count ?? 0;
const submissions = inbox?.submissions || [];
const totals = run?.totals;
const files = run?.files || [];
const rejections = files.flatMap((f) =>
(f.result?.rejections || []).map((r) => {
const where = r.row == null ? 'row unknown' : `row ${r.row}`;
const size = r.size ? ` ${r.size}` : '';
return `${where} · ${r.product_name || '(unnamed)'}${size} — ${r.reason}`;
})
);
const rowErrors = files.flatMap((f) =>
(f.result?.errors || []).map(
(e) => `row ${e.row} · ${e.product_name || '(unnamed)'} — ${e.error}`
)
);
const warnings = files.flatMap((f) => (f.result?.warnings || []).map((w) => w));
const storageErrors = files
.filter((f) => f.result?.storage_error)
.map((f) => `${f.filename} — ${f.result.storage_error}`);
const StatusIcon = STATUS_ICON[run?.status] || Clock;
return (
<div className="space-y-6">
{/* ---- the review gate, only when something is actually waiting ------
With UPLOAD_AUTORUN on this never renders. Turn the setting off and
it comes back on its own, which is the behaviour settings.py promises
- "the review inbox comes back with no code change". */}
{pending > 0 && (
<div className="rounded-2xl border border-amber-500/30 bg-amber-500/5 p-6 shadow-xs">
<h2 className="font-display text-lg font-bold text-ink-950 flex items-center gap-2 mb-2">
<AlertTriangle className="h-5 w-5 text-amber-600" /> Waiting for review
</h2>
<p className="text-xs text-slate-600 leading-relaxed max-w-3xl">
<code className="font-mono">UPLOAD_AUTORUN</code> is off, so these have been held
rather than run. Tick what you want and press <strong>Start selected</strong> to send
them through the 11-stage pipeline as one batch. Files from different drops can go in
the same batch.
</p>
<div className="mt-5 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={handleStart}
disabled={!selected.size || busy}
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-amber-500 text-slate-950 text-xs font-bold hover:bg-amber-600 disabled:opacity-40 transition cursor-pointer"
>
{busy === 'start'
? <Loader2 className="h-4 w-4 animate-spin" />
: <PlayCircle className="h-4 w-4" />}
Start selected{selected.size ? ` (${selected.size})` : ''}
<ArrowRight className="h-3.5 w-3.5" />
</button>
<button
type="button"
onClick={handleDismiss}
disabled={!selected.size || busy}
className="flex items-center gap-2 px-4 py-2 rounded-xl border border-ink-900/15 bg-white text-xs font-bold text-slate-600 hover:text-maroon-600 disabled:opacity-40 transition cursor-pointer"
>
{busy === 'dismiss'
? <Loader2 className="h-4 w-4 animate-spin" />
: <Ban className="h-4 w-4" />}
Dismiss
</button>
<span className="text-[11px] text-slate-500 font-mono">
{pending} file(s) awaiting review
</span>
</div>
{/* These govern the manual start above and nothing else. An upload
that ran itself never passed through here - the server reads
UPLOAD_AUTORUN_FETCH_IMAGES / UPLOAD_AUTORUN_USE_LLM and ignores
whatever a request asked for. What a given run actually used is
the img/llm pair on its own header below. */}
<fieldset className="mt-4 rounded-xl border border-ink-900/10 bg-white p-3">
<legend className="px-1 text-[10px] font-bold uppercase tracking-wide text-slate-400">
Applies to Start selected
</legend>
<div className="flex flex-col gap-2">
<label className="flex items-start gap-2 text-[11px] text-slate-600 cursor-pointer">
<input
type="checkbox"
checked={fetchImages}
onChange={(e) => setFetchImages(e.target.checked)}
className="mt-0.5 accent-amber-500 cursor-pointer"
/>
<span>
<span className="font-bold text-ink-950">Search for product images</span>
{' '}&mdash; much slower, and makes outbound requests for every row.
</span>
</label>
<label className="flex items-start gap-2 text-[11px] text-slate-600 cursor-pointer">
<input
type="checkbox"
checked={useLlm}
onChange={(e) => setUseLlm(e.target.checked)}
className="mt-0.5 accent-amber-500 cursor-pointer"
/>
<span>
<span className="font-bold text-ink-950">Use the LLM for missing descriptions</span>
{' '}&mdash; fills blank descriptions where an Ollama server is reachable.
</span>
</label>
</div>
</fieldset>
{notice && (
<div className="mt-4 rounded-lg bg-emerald-500/5 border border-emerald-500/20 p-3 text-xs text-emerald-700 flex items-start gap-2">
<CheckCircle2 className="h-4 w-4 shrink-0 mt-0.5" /> {notice}
</div>
)}
{error && (
<div className="mt-4 rounded-lg bg-maroon-100 border border-maroon-500/30 p-3 text-xs text-maroon-600 flex items-start gap-2">
<XCircle className="h-4 w-4 shrink-0 mt-0.5" /> {error}
</div>
)}
<div className="mt-4 space-y-3">
{submissions.map((submission) => {
const ids = submission.files.map((f) => f.file_id);
const allOn = ids.length > 0 && ids.every((id) => selected.has(id));
return (
<div
key={submission.submission_id}
className="rounded-xl border border-ink-900/10 bg-white p-4"
>
<div className="flex items-center justify-between gap-3 mb-3">
<h3 className="font-display text-xs font-bold text-ink-950">
from <span className="text-amber-600">{submission.submitted_by}</span>
<span className="ml-2 font-normal text-slate-400">
{ago(submission.created_at)} &middot; {submission.files.length} file(s)
</span>
</h3>
<button
type="button"
onClick={() => toggleDrop(submission)}
className="shrink-0 text-[11px] font-bold text-slate-500 hover:text-ink-950 transition cursor-pointer"
>
{allOn ? 'Clear all' : 'Select all'}
</button>
</div>
<ul className="space-y-1.5">
{submission.files.map((file) => (
<li key={file.file_id}>
<label className="flex items-center gap-3 px-3 py-2.5 rounded-xl border border-ink-900/10 bg-paper-50 cursor-pointer hover:border-amber-500/40 transition">
<input
type="checkbox"
checked={selected.has(file.file_id)}
onChange={() => toggle(file.file_id)}
className="accent-amber-500 cursor-pointer"
/>
<FileSpreadsheet className="h-4 w-4 text-amber-600 shrink-0" />
<span className="text-[11px] font-bold text-ink-950 truncate flex-1">
{file.filename}
</span>
<span className="font-mono text-[10px] text-slate-400 shrink-0">
{file.rows_total} rows
</span>
<span className="font-mono text-[10px] text-slate-300 shrink-0 w-16 text-right">
{fmtBytes(file.size_bytes)}
</span>
</label>
</li>
))}
</ul>
</div>
);
})}
</div>
</div>
)}
{/* ---- what the page is for ---------------------------------------- */}
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="font-display text-lg font-bold text-ink-950 flex items-center gap-2 mb-2">
<ClipboardCheck className="h-5 w-5 text-amber-500" /> Upload Results
</h2>
<p className="text-xs text-slate-500 leading-relaxed max-w-3xl">
The most recent spreadsheet a colleague sent to{' '}
<code className="font-mono">/api/uploads/catalog</code>, and everything the
11-stage pipeline did with it &mdash; including which rows were refused and why.
It updates on its own; there is nothing to pick. Your own uploads live under{' '}
<strong>Batch Catalog Ingestion</strong>.
</p>
</div>
<button
type="button"
onClick={() => {
// An explicit Refresh buys back the widened lookup, so an admin
// who knows a colleague just uploaded can force the deep search.
escalatedRef.current = false;
discover();
}}
aria-label="Check for a newer upload"
className="shrink-0 flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-ink-900/15 bg-white text-[11px] font-bold text-slate-600 hover:text-ink-950 transition cursor-pointer"
>
<RefreshCw className="h-3.5 w-3.5" /> Refresh
</button>
</div>
</div>
{/* ---- the run ------------------------------------------------------ */}
{!discovered && (
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-10 text-center">
<Loader2 className="h-5 w-5 animate-spin text-amber-500 mx-auto" />
</div>
)}
{/* Gate open but nothing has run: "no upload yet" would be flatly false -
there is one, it is sitting above waiting for a decision. */}
{discovered && !run && pending > 0 && (
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-10 text-center">
<Clock className="h-8 w-8 text-slate-300 mx-auto mb-3" />
<p className="text-xs text-slate-500">
Nothing has run yet &mdash; the upload above is waiting for your approval. Tick the
files and press <strong>Start selected</strong>, and its stage breakdown appears here.
</p>
</div>
)}
{discovered && !run && pending === 0 && (
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-10 text-center">
<ClipboardCheck className="h-8 w-8 text-slate-300 mx-auto mb-3" />
<p className="text-xs text-slate-500">
No colleague upload yet. The moment one arrives at{' '}
<code className="font-mono">POST /api/uploads/catalog</code>, its run appears here
with the full stage breakdown.
</p>
<p className="text-[11px] text-slate-400 mt-2">
Files you upload yourself are not shown here &mdash; they are under{' '}
<strong>Batch Catalog Ingestion</strong>. Uploads older than a week are cleared by
retention.
</p>
</div>
)}
{run && (
<div className="rounded-2xl border border-ink-900/10 bg-paper-50 p-6 shadow-xs">
<div className="flex items-start justify-between gap-3 mb-1">
<h3 className="font-display text-sm font-bold text-ink-950 flex items-center gap-2">
<StatusIcon
className={`h-4 w-4 ${STATUS_TONE[run.status] || 'text-slate-400'} ${
run.status === 'running' ? 'animate-spin' : ''
}`}
/>
from <span className="text-amber-600">{run.submitted_by}</span>
<span className="font-normal text-slate-400">
{ago(run.created_at)} &middot; {run.files_total} file(s)
</span>
</h3>
<div className="flex items-center gap-2 shrink-0">
{STAGE_FLAGS.map(({ key, label, title }) => (
<span
key={key}
title={title}
className={`rounded-full px-2 py-0.5 text-[10px] font-bold border ${
run[key]
? 'bg-amber-500/15 border-amber-500/30 text-amber-600'
: 'bg-white border-ink-900/10 text-slate-300 line-through'
}`}
>
{label}
</span>
))}
<span className="font-mono text-[11px] uppercase text-slate-400">{run.status}</span>
</div>
</div>
<p className="font-mono text-[10px] text-slate-400 mb-4">{run.batch_id}</p>
{/* `partial` exists so this does not read as success. Some files
landed and some did not, and the counters below are the total of
only the ones that did. */}
{run.status === 'partial' && (
<div className="mb-4 rounded-lg bg-amber-500/5 border border-amber-500/20 p-3 text-[11px] text-amber-700 flex items-start gap-2">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
<span>
{run.files_done} of {run.files_total} file(s) landed; {run.files_failed} did not.
The counters below cover only what succeeded.
</span>
</div>
)}
{run.status === 'queued' && (
<div className="mb-4 rounded-lg bg-white border border-ink-900/10 p-3 text-[11px] text-slate-500 flex items-start gap-2">
<Clock className="h-4 w-4 shrink-0 mt-0.5" />
<span>
{run.runner === 'dagster'
? 'Staged and waiting for the Dagster orchestrator to claim it. Dagster is not deployed in production, so this will wait indefinitely there.'
: 'Staged and waiting its turn - the ingestion queue was busy when this arrived.'}
</span>
</div>
)}
{run.status === 'interrupted' && (
<div className="mb-4 rounded-lg bg-amber-500/5 border border-amber-500/20 p-3 text-[11px] text-amber-700 flex items-start gap-2">
<AlertTriangle className="h-4 w-4 shrink-0 mt-0.5" />
<span>
The backend restarted while this was running, so its unfinished files were put
back in the queue. Resume it from <strong>Batch Catalog Ingestion</strong>.
</span>
</div>
)}
{run.detail && (
<p className="mb-4 text-[11px] text-slate-500 leading-relaxed">{run.detail}</p>
)}
{/* A storage error still leaves rows built, so it hides among the
counters - but it is what forces the file to failed, so it is
called out on its own. */}
{storageErrors.length > 0 && (
<div className="mb-4 rounded-lg bg-maroon-100 border border-maroon-500/30 p-3 text-[11px] text-maroon-600">
<p className="font-bold mb-1">Could not write to the catalogue</p>
{storageErrors.map((text, i) => (
<p key={i} className="leading-relaxed">{text}</p>
))}
</div>
)}
<ul className="space-y-3">
{files.map((file) => (
<FileStages
key={`${file.index}-${file.filename}`}
file={file}
stageNames={run.stage_names || []}
/>
))}
</ul>
{/* Gated on something having landed, so a still-queued run shows the
stage list rather than a wall of zeros. */}
{totals && (run.files_done > 0 || run.files_failed > 0) && (
<div className="mt-5 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-6 gap-3">
<Stat label="Rows read" value={totals.rows_total} tone="slate" />
<Stat label="Products built" value={totals.products_built} tone="blue" />
<Stat label="Inserted" value={totals.inserted} tone="emerald" />
<Stat label="Backfilled" value={totals.backfilled} tone="amber" />
<Stat label="Unchanged" value={totals.skipped_existing} tone="slate" />
<Stat label="Rejected" value={totals.rejected} tone="maroon" />
</div>
)}
{run.brands?.length > 0 && (
<p className="mt-4 text-[11px] text-slate-500">
Brands touched:{' '}
<span className="font-bold text-ink-950">{run.brands.join(', ')}</span>
</p>
)}
{/* The reason this screen exists rather than sending people to the
Dagster tab: a refused row, named, with the sheet line number the
sender sees on screen (header counted as row 1). */}
<IssueList
title="Refused by the validation gate"
items={rejections}
tone="maroon"
/>
<IssueList title="Rows that could not be read" items={rowErrors} />
<IssueList title="Warnings" items={warnings} />
</div>
)}
</div>
);
}