gendesign/tradein-mvp/frontend/src/components/scrapers/PacingControl.tsx
bot-backend 00cd30b765
All checks were successful
CI / changes (pull_request) Successful in 6s
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(scrapers/ui): backfill+full-load триггеры, source-фильтр runs, pacing-слайдер, coverage-таблица
Часть 1 — недостающие триггеры:
- CianBackfillSection: cian-backfill-history, cian-price-history (T8a), cian-full-load
- YandexBackfillSection: yandex-address-backfill (T10), yandex-full-load, yandex-newbuilding-sweep
- CianAutoLoginButton: POST /scrape/cian/auto-login в секцию Cookies
- HouseImvBackfillSection: house-imv-backfill (T7) в системный раздел
- GeocodeSection: geocode-missing-listings trigger + status polling

Часть 2 — source-фильтр в RunsTable:
- dropdown «Все / Avito / Cian / Yandex» + aria-label
- фильтр прокидывается в queryKey и query-param ?source=

Часть 3 — PacingControl:
- GET /api/v1/admin/scraper/pacing → PacingSection
- number-input + range slider (0–60с, шаг 0.5) + PUT /pacing/{source}
- env_default_s подсказка; graceful-degradation на ошибку/404

Часть 4 — DataQualitySection:
- GET /api/v1/admin/scraper/data-quality → таблица source × поле (% fill)
- <50% → красный акцент; th scope="col"/"row" a11y
- блок houses enrichment метрик; graceful-degradation на ошибку/404
2026-06-20 12:50:04 +03:00

198 lines
6.5 KiB
TypeScript
Raw Permalink 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, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api";
// ── Types (per contract) ───────────────────────────────────────────────────
interface PacingProvider {
source: "avito" | "cian" | "yandex" | "generic";
interval_s: number;
env_default_s: number;
}
interface PacingResp {
providers: PacingProvider[];
}
interface PacingUpdateResp {
ok: boolean;
source: string;
interval_s: number;
}
// ── Hooks ──────────────────────────────────────────────────────────────────
function usePacing() {
return useQuery<PacingResp>({
queryKey: ["scraper-pacing"],
queryFn: () => apiFetch<PacingResp>("/api/v1/admin/scraper/pacing"),
staleTime: 30_000,
retry: 1,
});
}
function useUpdatePacing(source: string) {
const qc = useQueryClient();
return useMutation<PacingUpdateResp, Error, { interval_s: number }>({
mutationFn: ({ interval_s }) =>
apiFetch<PacingUpdateResp>(
`/api/v1/admin/scraper/pacing/${encodeURIComponent(source)}`,
{
method: "PUT",
body: JSON.stringify({ interval_s }),
},
),
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ["scraper-pacing"] });
},
});
}
// ── Per-provider row ───────────────────────────────────────────────────────
interface PacingRowProps {
provider: PacingProvider;
}
function PacingRow({ provider }: PacingRowProps) {
const [value, setValue] = useState<string>(String(provider.interval_s));
const updateMut = useUpdatePacing(provider.source);
// Sync when upstream data refreshes
useEffect(() => {
setValue(String(provider.interval_s));
}, [provider.interval_s]);
const numValue = parseFloat(value);
const isValid = !isNaN(numValue) && numValue >= 0 && numValue <= 60;
return (
<tr>
<td>
<code style={{ fontWeight: 600 }}>{provider.source}</code>
</td>
<td>
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
<input
type="number"
min={0}
max={60}
step={0.5}
value={value}
onChange={(e) => setValue(e.target.value)}
aria-label={`Pacing interval for ${provider.source} (seconds)`}
style={{
width: 72,
padding: "3px 6px",
fontSize: "0.85rem",
border: "1px solid var(--border-card, #e6e8ec)",
borderRadius: 6,
}}
/>
<input
type="range"
min={0}
max={60}
step={0.5}
value={isValid ? numValue : 0}
onChange={(e) => setValue(e.target.value)}
aria-label={`Pacing slider for ${provider.source}`}
style={{ width: 120 }}
/>
<span style={{ fontSize: "0.8rem", color: "var(--fg-tertiary, #73767e)" }}>
с (по умолч. {provider.env_default_s} с сбросится при рестарте)
</span>
</div>
</td>
<td>
<button
type="button"
disabled={updateMut.isPending || !isValid}
onClick={() => updateMut.mutate({ interval_s: numValue })}
style={{ fontSize: "0.8rem", padding: "3px 10px" }}
>
{updateMut.isPending ? "…" : "Применить"}
</button>
{updateMut.isSuccess && updateMut.data.ok && (
<span
style={{ marginLeft: 8, fontSize: "0.75rem", color: "var(--success, #0a7a3a)" }}
>
{updateMut.data.interval_s} с ок
</span>
)}
{updateMut.isSuccess && !updateMut.data.ok && (
<span
style={{ marginLeft: 8, fontSize: "0.75rem", color: "var(--danger, #b3261e)" }}
>
не применено
</span>
)}
{updateMut.isError && (
<span
style={{ marginLeft: 8, fontSize: "0.75rem", color: "var(--danger, #b3261e)" }}
>
{updateMut.error.message}
</span>
)}
</td>
</tr>
);
}
// ── PacingSection (exported) ───────────────────────────────────────────────
export function PacingSection() {
const pacingQ = usePacing();
return (
<section className="scraper-section">
<h2>Pacing (интервалы запросов)</h2>
<p className="scraper-hint">
Минимальный интервал между запросами скраппера для каждого провайдера.
0 = без искусственной паузы. Изменение вступает в силу немедленно;
сбрасывается при рестарте контейнера (постоянное значение через ENV).
</p>
{/* Graceful degradation: API not yet deployed */}
{pacingQ.isError && (
<p
className="scraper-result scraper-result--error"
style={{ marginBottom: 0 }}
>
pacing API недоступен
{" — "}
<span style={{ color: "var(--fg-secondary, #5b6066)" }}>
{pacingQ.error.message}
</span>
</p>
)}
{pacingQ.isPending && (
<p className="scraper-hint">Загрузка pacing</p>
)}
{pacingQ.isSuccess && pacingQ.data.providers.length === 0 && (
<p className="scraper-hint">Нет данных pacing от API.</p>
)}
{pacingQ.isSuccess && pacingQ.data.providers.length > 0 && (
<table className="runs-table">
<thead>
<tr>
<th scope="col">Провайдер</th>
<th scope="col">Интервал (060 с)</th>
<th scope="col">Действие</th>
</tr>
</thead>
<tbody>
{pacingQ.data.providers.map((p) => (
<PacingRow key={p.source} provider={p} />
))}
</tbody>
</table>
)}
</section>
);
}