Часть 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
198 lines
6.5 KiB
TypeScript
198 lines
6.5 KiB
TypeScript
"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">Интервал (0–60 с)</th>
|
||
<th scope="col">Действие</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{pacingQ.data.providers.map((p) => (
|
||
<PacingRow key={p.source} provider={p} />
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|