gendesign/tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx
bot-backend dd339d3302
All checks were successful
CI / changes (pull_request) Successful in 4s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
feat(tradein-frontend): единая scrapers-страница — табы Avito/Cian/Yandex, proxy-health+rotate, runs-история, консолидация триггеров, удалён cian-cookies редирект
2026-06-20 09:33:07 +03:00

329 lines
9.9 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.

"use client";
import React, { useState } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api";
import {
formatTime,
translateStatus,
useCancelCitySweep,
type ScraperSource,
} from "@/app/scrapers/_components/ScraperPage";
// ── Types ──────────────────────────────────────────────────────────────────
export interface ScrapeRunFull {
run_id: number;
source: string;
run_type: string | null;
status: string;
params: Record<string, unknown> | null;
counters: Record<string, number | string | null> | null;
total_seen: number | null;
new_count: number | null;
started_at: string | null;
finished_at: string | null;
heartbeat_at: string | null;
error_text: string | null;
}
interface RunsListResp {
total: number;
rows: ScrapeRunFull[];
}
// ── Hook ───────────────────────────────────────────────────────────────────
const RUN_STATUS_ALL = ["", "running", "done", "failed", "cancelled", "zombie", "banned"] as const;
type RunStatusFilter = (typeof RUN_STATUS_ALL)[number];
function useScraperRuns(
source: ScraperSource,
status: RunStatusFilter,
limit = 20,
) {
return useQuery<RunsListResp>({
queryKey: ["scrape-runs", source, status, limit],
queryFn: () => {
const qs = new URLSearchParams({ source, limit: String(limit) });
if (status) qs.set("status", status);
return apiFetch<RunsListResp>(
`/api/v1/admin/scrape/runs?${qs.toString()}`,
);
},
refetchInterval: 8_000,
staleTime: 0,
retry: 1,
});
}
// ── Status badge ───────────────────────────────────────────────────────────
function RunStatusBadge({ status }: { status: string }) {
let variant: "running" | "done" | "danger" | "neutral";
switch (status) {
case "running":
variant = "running";
break;
case "done":
variant = "done";
break;
case "failed":
case "banned":
case "zombie":
variant = "danger";
break;
default:
variant = "neutral";
}
const styles: Record<typeof variant, React.CSSProperties> = {
running: {
background: "var(--accent-soft, #dbeafe)",
color: "var(--accent, #1d4ed8)",
},
done: {
background: "var(--success-soft, #dcfce7)",
color: "var(--success, #0a7a3a)",
},
danger: {
background: "var(--danger-soft, #fee2e2)",
color: "var(--danger, #b3261e)",
},
neutral: {
background: "var(--bg-card-alt, #fafbfc)",
color: "var(--fg-secondary, #5b6066)",
},
};
return (
<span
style={{
display: "inline-block",
padding: "2px 8px",
borderRadius: 6,
fontSize: "0.8rem",
fontWeight: 600,
...styles[variant],
}}
>
{translateStatus(status)}
</span>
);
}
// ── Counters cell ──────────────────────────────────────────────────────────
function CountersCell({
row,
}: {
row: ScrapeRunFull;
}) {
const c = row.counters;
if (!c) return <span style={{ color: "var(--fg-tertiary, #73767e)" }}></span>;
const parts: string[] = [];
const lots =
c.lots_fetched != null ? `${c.lots_fetched} лотов` : null;
const newC =
row.new_count != null
? `+${row.new_count} new`
: c.lots_inserted != null
? `+${c.lots_inserted} new`
: null;
const errs =
c.errors_count != null && Number(c.errors_count) > 0
? `${c.errors_count} err`
: null;
if (lots) parts.push(lots);
if (newC) parts.push(newC);
if (errs) parts.push(errs);
return (
<span style={{ fontSize: "0.8rem" }}>
{parts.length > 0 ? parts.join(" · ") : "—"}
</span>
);
}
// ── RunsTable ──────────────────────────────────────────────────────────────
interface RunsTableProps {
source: ScraperSource;
}
export function RunsTable({ source }: RunsTableProps) {
const [statusFilter, setStatusFilter] =
useState<RunStatusFilter>("");
const qc = useQueryClient();
const runsQ = useScraperRuns(source, statusFilter);
const cancelMut = useCancelCitySweep(source);
function handleCancel(runId: number) {
cancelMut.mutate(runId, {
onSuccess: () => {
void qc.invalidateQueries({
queryKey: ["scrape-runs", source, statusFilter],
});
},
});
}
return (
<section className="scraper-section">
<h2>История прогонов</h2>
<p className="scraper-hint">
Последние 20 прогонов. Автообновление каждые 8 сек.
</p>
{/* Status filter */}
<div style={{ marginBottom: 12, display: "flex", gap: 8, flexWrap: "wrap" }}>
{RUN_STATUS_ALL.map((s) => (
<button
key={s || "all"}
type="button"
onClick={() => setStatusFilter(s)}
style={{
padding: "3px 10px",
borderRadius: 6,
border: "1px solid",
fontSize: "0.8rem",
cursor: "pointer",
background:
statusFilter === s
? "var(--accent, #1d4ed8)"
: "var(--bg-card, #fff)",
color:
statusFilter === s
? "#fff"
: "var(--fg-secondary, #5b6066)",
borderColor:
statusFilter === s
? "var(--accent, #1d4ed8)"
: "var(--border-card, #e6e8ec)",
}}
>
{s === "" ? "Все" : translateStatus(s)}
</button>
))}
</div>
{runsQ.isPending && <p className="scraper-hint">Загрузка</p>}
{runsQ.isError && (
<p className="scraper-result scraper-result--error">
Ошибка загрузки: {runsQ.error.message}
{" — "}
<span style={{ color: "var(--fg-secondary, #5b6066)" }}>
API /scrape/runs ещё не задеплоен
</span>
</p>
)}
{runsQ.data && runsQ.data.rows.length === 0 && (
<p className="scraper-hint">Прогонов не найдено.</p>
)}
{runsQ.data && runsQ.data.rows.length > 0 && (
<>
<p
style={{
fontSize: "0.8rem",
color: "var(--fg-tertiary, #73767e)",
marginBottom: 8,
}}
>
Итого: {runsQ.data.total}
</p>
<table className="runs-table">
<thead>
<tr>
<th>#</th>
<th>Тип</th>
<th>Статус</th>
<th>Старт</th>
<th>Финиш</th>
<th>Лоты / Ошибки</th>
<th>Действие</th>
</tr>
</thead>
<tbody>
{runsQ.data.rows.map((r) => (
<tr
key={r.run_id}
className={`run-row run-row--${r.status}`}
>
<td>{r.run_id}</td>
<td>
<span
style={{
fontSize: "0.8rem",
color: "var(--fg-secondary, #5b6066)",
}}
>
{r.run_type ?? "sweep"}
</span>
</td>
<td>
<RunStatusBadge status={r.status} />
</td>
<td
style={{
fontSize: "0.8rem",
whiteSpace: "nowrap",
}}
>
{formatTime(r.started_at)}
</td>
<td
style={{
fontSize: "0.8rem",
whiteSpace: "nowrap",
}}
>
{formatTime(r.finished_at)}
</td>
<td>
<CountersCell row={r} />
{r.error_text && (
<div
style={{
fontSize: "0.75rem",
color: "var(--danger, #b3261e)",
marginTop: 2,
maxWidth: 240,
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
title={r.error_text}
>
{r.error_text}
</div>
)}
</td>
<td>
{r.status === "running" && (
<button
type="button"
className="cancel-btn"
disabled={
cancelMut.isPending &&
cancelMut.variables === r.run_id
}
onClick={() => handleCancel(r.run_id)}
style={{ fontSize: "0.8rem", padding: "3px 10px" }}
>
Отменить
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</section>
);
}