329 lines
9.9 KiB
TypeScript
329 lines
9.9 KiB
TypeScript
"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>
|
||
);
|
||
}
|