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
Co-authored-by: bot-frontend <bot-frontend@gendsgn.local> Co-committed-by: bot-frontend <bot-frontend@gendsgn.local>
423 lines
14 KiB
TypeScript
423 lines
14 KiB
TypeScript
"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 (сек, 0–30)
|
||
<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>
|
||
</>
|
||
);
|
||
}
|