gendesign/tradein-mvp/frontend/src/app/scrapers/page.tsx
bot-frontend ee8f72da39
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m37s
Deploy Trade-In / deploy (push) Successful in 36s
feat(tradein): unify scraper admin pages (avito/cian/yandex) + /scrapers hub (#878)
Co-authored-by: bot-frontend <bot-frontend@gendsgn.local>
Co-committed-by: bot-frontend <bot-frontend@gendsgn.local>
2026-05-31 09:31:47 +00:00

423 lines
14 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 { useEffect, useState } from "react";
import "@/components/trade-in/trade-in.css";
import { Topbar } from "@/components/trade-in/Topbar";
import { API_BASE_URL } from "@/lib/api";
import {
formatTime,
translateStatus,
useScraperSettings,
useSchedules,
useUpdateScraperSetting,
useUpdateSchedule,
utcToMsk,
type ScheduleConfig,
type ScheduleSourceKey,
} from "@/app/scrapers/_components/ScraperPage";
// ── Global delay section ───────────────────────────────────────────────────
function GlobalDelaySetting() {
const settingsQ = useScraperSettings();
const updateMut = useUpdateScraperSetting();
const globalSetting = settingsQ.data?.find((s) => s.source === "global");
const [delayInput, setDelayInput] = useState("0");
const [initialized, setInitialized] = useState(false);
useEffect(() => {
if (globalSetting && !initialized) {
setDelayInput(String(globalSetting.request_delay_sec));
setInitialized(true);
}
}, [globalSetting, initialized]);
return (
<section className="scraper-section scraper-section--highlight">
<h2>Глобальная задержка запросов (нижний порог)</h2>
<p className="scraper-hint">
Глобальный делэй нижний порог для всех скрапперов (max с per-source
defaults). Если 0 только per-source defaults. Изменение вступает в
силу немедленно без перезапуска процесса. Для per-source задержек
зайдите на страницу конкретного скраппера.
</p>
{settingsQ.isPending && (
<p className="scraper-hint">Загрузка настроек</p>
)}
{settingsQ.error && (
<p className="scraper-result scraper-result--error">
Ошибка загрузки: {settingsQ.error.message}
</p>
)}
{globalSetting && (
<div className="schedule-status" style={{ marginBottom: "12px" }}>
<div className="schedule-status__row">
<span className="schedule-status__label">Текущий global delay:</span>
<span>
<strong>{globalSetting.request_delay_sec} сек</strong>
</span>
</div>
{globalSetting.updated_at && (
<div className="schedule-status__row">
<span className="schedule-status__label">Обновлено:</span>
<span>{formatTime(globalSetting.updated_at)}</span>
</div>
)}
</div>
)}
{/* Per-source read-only table */}
{settingsQ.data && settingsQ.data.length > 0 && (
<div style={{ marginBottom: "16px" }}>
<p
style={{
fontSize: "0.85rem",
fontWeight: 600,
marginBottom: "6px",
}}
>
Все per-source задержки (read-only здесь, редактировать на
страницах провайдеров):
</p>
<table className="runs-table">
<thead>
<tr>
<th>Source</th>
<th>Delay (сек)</th>
<th>Описание</th>
<th>Обновлено</th>
</tr>
</thead>
<tbody>
{settingsQ.data.map((s) => (
<tr
key={s.source}
className={
s.source === "global"
? "run-row run-row--running"
: "run-row"
}
>
<td>
<code>{s.source}</code>
</td>
<td>{s.request_delay_sec.toFixed(1)}</td>
<td
style={{
fontSize: "0.8rem",
color: "var(--muted, #5b6066)",
}}
>
{s.description ?? "—"}
</td>
<td style={{ fontSize: "0.8rem" }}>
{formatTime(s.updated_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<form
className="scraper-form"
onSubmit={(e) => {
e.preventDefault();
updateMut.mutate({
source: "global",
request_delay_sec: parseFloat(delayInput),
});
}}
>
<label>
Global delay (сек, 030)
<input
type="number"
step={0.5}
min={0}
max={30}
value={delayInput}
onChange={(e) => setDelayInput(e.target.value)}
/>
</label>
<input
type="range"
min={0}
max={30}
step={0.5}
value={delayInput}
onChange={(e) => setDelayInput(e.target.value)}
style={{ width: "100%", gridColumn: "1 / -1" }}
/>
<button
type="submit"
disabled={updateMut.isPending}
style={{ gridColumn: "1 / -1" }}
>
{updateMut.isPending ? "Сохраняем…" : "Сохранить global delay"}
</button>
</form>
{updateMut.error && (
<div className="scraper-result scraper-result--error">
Ошибка: {updateMut.error.message}
</div>
)}
{updateMut.isSuccess && updateMut.data && (
<div className="scraper-result">
Global delay обновлён:{" "}
<strong>
{Number(updateMut.data.request_delay_sec).toFixed(1)} сек
</strong>{" "}
(обновлено {formatTime(updateMut.data.updated_at)})
</div>
)}
</section>
);
}
// ── Schedules summary table ────────────────────────────────────────────────
const SCHEDULE_SOURCE_LABELS: Record<string, string> = {
avito_city_sweep: "Avito city sweep",
cian_city_sweep: "Cian city sweep",
yandex_city_sweep: "Yandex city sweep",
};
const SCHEDULE_SOURCES: ScheduleSourceKey[] = [
"avito_city_sweep",
"cian_city_sweep",
"yandex_city_sweep",
];
function SchedulesSummary() {
const schedulesQ = useSchedules();
const updateMut = useUpdateSchedule();
function handleToggle(schedule: ScheduleConfig) {
updateMut.mutate({
source: schedule.source as ScheduleSourceKey,
payload: {
enabled: !schedule.enabled,
window_start_hour: schedule.window_start_hour,
window_end_hour: schedule.window_end_hour,
default_params: schedule.default_params as Record<string, unknown>,
},
});
}
// Build ordered rows from known sources + any extras
const knownRows = SCHEDULE_SOURCES.map((src) =>
schedulesQ.data?.find((s) => s.source === src),
).filter((s): s is ScheduleConfig => s !== undefined);
const extraRows =
schedulesQ.data?.filter(
(s) => !SCHEDULE_SOURCES.includes(s.source as ScheduleSourceKey),
) ?? [];
const rows = [...knownRows, ...extraRows];
return (
<section className="scraper-section">
<h2>Сводная таблица расписаний</h2>
<p className="scraper-hint">
Быстрое включение/отключение всех cron-расписаний. Параметры и окна
настраиваются на страницах провайдеров. Автообновление каждые 30 сек.
</p>
{schedulesQ.isPending && <p className="scraper-hint">Загрузка</p>}
{schedulesQ.error && (
<p className="scraper-result scraper-result--error">
Ошибка загрузки: {schedulesQ.error.message}
</p>
)}
{rows.length > 0 && (
<table className="runs-table">
<thead>
<tr>
<th>Source</th>
<th>Статус</th>
<th>Окно (МСК)</th>
<th>Следующий запуск</th>
<th>Последний запуск</th>
<th>Последний run</th>
<th>Вкл/выкл</th>
</tr>
</thead>
<tbody>
{rows.map((s) => {
const mskStart = utcToMsk(s.window_start_hour);
const mskEnd = utcToMsk(s.window_end_hour);
const label =
SCHEDULE_SOURCE_LABELS[s.source] ?? s.source;
return (
<tr key={s.source} className="run-row">
<td>
<strong>{label}</strong>
</td>
<td>
<span
className={`run-badge run-badge--${s.enabled ? "running" : "cancelled"}`}
>
{s.enabled ? "включено" : "отключено"}
</span>
</td>
<td>
{String(mskStart).padStart(2, "0")}:00
{String(mskEnd).padStart(2, "0")}:00 МСК
</td>
<td>{formatTime(s.next_run_at)}</td>
<td>{formatTime(s.last_run_at)}</td>
<td>
{s.last_run_id ? (
<span className="run-muted">#{s.last_run_id}</span>
) : (
"—"
)}
</td>
<td>
<button
type="button"
className="cancel-btn"
disabled={updateMut.isPending}
onClick={() => handleToggle(s)}
style={
s.enabled
? undefined
: { background: "var(--success, #27ae60)" }
}
>
{s.enabled ? "Отключить" : "Включить"}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
)}
{rows.length === 0 && !schedulesQ.isPending && !schedulesQ.error && (
<p className="scraper-hint">Расписания не найдены. Они создаются автоматически при первом сохранении на странице провайдера.</p>
)}
{updateMut.error && (
<div className="scraper-result scraper-result--error">
Ошибка обновления: {updateMut.error.message}
</div>
)}
{updateMut.isSuccess && (
<div className="scraper-result">
Расписание {translateStatus(
updateMut.data.enabled ? "running" : "cancelled",
)}.
</div>
)}
</section>
);
}
// ── Provider cards ─────────────────────────────────────────────────────────
const PROVIDERS = [
{
key: "avito",
name: "Avito",
href: "/scrapers/avito",
description:
"City sweep, houses catalog, detail enrichment, IMV evaluation",
},
{
key: "cian",
name: "Cian",
href: "/scrapers/cian",
description: "City sweep, detail enrichment, новостройки ЖК, cookies",
},
{
key: "yandex",
name: "Yandex Realty",
href: "/scrapers/yandex",
description: "City sweep, detail, ЖК landing, valuation",
},
];
function ProviderCards() {
return (
<section className="scraper-section">
<h2>Провайдеры</h2>
<div
style={{
display: "grid",
gridTemplateColumns: "repeat(auto-fit, minmax(240px, 1fr))",
gap: "16px",
marginTop: "12px",
}}
>
{PROVIDERS.map((p) => (
<a
key={p.key}
href={`${API_BASE_URL}${p.href}`}
style={{
display: "block",
padding: "16px 20px",
border: "1px solid var(--border, #e5e7eb)",
borderRadius: 8,
background: "var(--surface, #fff)",
textDecoration: "none",
color: "inherit",
transition: "border-color 0.15s, box-shadow 0.15s",
}}
>
<p
style={{
margin: "0 0 4px",
fontWeight: 700,
fontSize: "1rem",
color: "var(--accent, #1d4ed8)",
}}
>
{p.name}
</p>
<p
style={{
margin: 0,
fontSize: "0.85rem",
color: "var(--muted, #5b6066)",
}}
>
{p.description}
</p>
</a>
))}
</div>
</section>
);
}
// ── Page ───────────────────────────────────────────────────────────────────
export default function ScrapersHubPage() {
return (
<>
<Topbar active="scrapers" />
<main className="page scraper-page">
<h1 className="scraper-h1">Скрапперы хаб</h1>
<p className="scraper-subtitle">
Центр управления всеми скрапперами: глобальные настройки задержки,
сводная таблица расписаний, ссылки на admin-триггеры провайдеров.
</p>
<ProviderCards />
<GlobalDelaySetting />
<SchedulesSummary />
</main>
</>
);
}