Merge pull request 'feat(tradein-frontend): единая scrapers-страница — табы, proxy-health+rotate, runs-история (scrapers UI Phase 1)' (#1819) from feat/scrapers-unified-page into main
All checks were successful
Deploy Trade-In / changes (push) Successful in 5s
Deploy Trade-In / test (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / build-backend (push) Has been skipped
Deploy Trade-In / build-frontend (push) Successful in 1m43s
Deploy Trade-In / deploy (push) Successful in 45s

Reviewed-on: #1819
This commit is contained in:
lekss361 2026-06-20 06:40:55 +00:00
commit 86f4602f1d
7 changed files with 2544 additions and 330 deletions

View file

@ -1,21 +0,0 @@
"use client";
import { useEffect } from "react";
import { useRouter } from "next/navigation";
/**
* /scrapers/cian-cookies redirects to /scrapers/cian where cookie management
* is now integrated as a dedicated section at the bottom of the Cian page.
*/
export default function CianCookiesRedirect() {
const router = useRouter();
useEffect(() => {
router.replace("/scrapers/cian");
}, [router]);
return (
<p style={{ padding: "2rem", fontFamily: "sans-serif" }}>
Перенаправление на страницу Cian скраппера
</p>
);
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,137 @@
"use client";
import {
useScraperHealth,
useRotateIp,
} from "@/components/scrapers/ProxyHealthCard";
// ── Per-provider proxy section (inside provider tab) ───────────────────────
interface ProviderProxySectionProps {
source: string;
}
export function ProviderProxySection({ source }: ProviderProxySectionProps) {
const healthQ = useScraperHealth();
const rotateMut = useRotateIp(source);
const provider = healthQ.data?.providers.find((p) => p.source === source);
if (healthQ.isPending) {
return (
<section className="scraper-section">
<h2>Прокси</h2>
<p className="scraper-hint">Загрузка</p>
</section>
);
}
if (healthQ.isError || !provider) {
return (
<section className="scraper-section">
<h2>Прокси</h2>
<p
className="scraper-result scraper-result--error"
style={{ marginBottom: 0 }}
>
{healthQ.isError
? `Ошибка: ${healthQ.error.message}`
: "Провайдер не найден в health-ответе"}
</p>
</section>
);
}
const proxyStr =
provider.proxy_host && provider.proxy_port
? `${provider.proxy_host}:${provider.proxy_port}`
: null;
return (
<section className="scraper-section">
<h2>Прокси</h2>
<div className="schedule-status" style={{ marginBottom: 12 }}>
<div className="schedule-status__row">
<span className="schedule-status__label">Прокси:</span>
<span>
{proxyStr ? (
<code style={{ fontSize: "0.85rem" }}>{proxyStr}</code>
) : (
<span style={{ color: "var(--fg-tertiary, #73767e)" }}>
не настроен
</span>
)}
</span>
</div>
<div className="schedule-status__row">
<span className="schedule-status__label">Текущий IP:</span>
<span>
{provider.current_ip ? (
<code style={{ fontSize: "0.85rem" }}>
{provider.current_ip}
</code>
) : (
<span style={{ color: "var(--fg-tertiary, #73767e)" }}></span>
)}
</span>
</div>
<div className="schedule-status__row">
<span className="schedule-status__label">Режим:</span>
<span
style={{
fontSize: "0.85rem",
color: "var(--fg-secondary, #5b6066)",
}}
>
{provider.rotate_supported ? "rotate-поддержка" : "статический"}
</span>
</div>
</div>
{provider.rotate_supported && (
<div>
<button
type="button"
disabled={rotateMut.isPending}
onClick={() => rotateMut.mutate()}
>
{rotateMut.isPending ? "Ротируем…" : "Сменить IP"}
</button>
{rotateMut.isSuccess && (
<div
className="scraper-result"
style={{ marginTop: 8 }}
>
{rotateMut.data.ok ? (
<>
Новый IP: <strong>{rotateMut.data.new_ip ?? "обновлён"}</strong>
</>
) : (
<span style={{ color: "var(--danger, #b3261e)" }}>
{rotateMut.data.reason ?? "ошибка ротации"}
</span>
)}
</div>
)}
{rotateMut.isError && (
<div className="scraper-result scraper-result--error" style={{ marginTop: 8 }}>
Ошибка: {rotateMut.error.message}
</div>
)}
</div>
)}
{!provider.rotate_supported && (
<p
className="scraper-hint"
style={{ margin: 0 }}
>
Ротация IP не поддерживается для этого провайдера.
</p>
)}
</section>
);
}

View file

@ -0,0 +1,320 @@
"use client";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiFetch } from "@/lib/api";
// ── Types ──────────────────────────────────────────────────────────────────
export interface ProviderHealth {
source: string;
proxy_host: string | null;
proxy_port: number | null;
rotate_supported: boolean;
current_ip: string | null;
}
export interface BrowserHealth {
reachable: boolean;
browsers: Record<string, boolean>;
}
export interface ScraperHealthResp {
fetch_mode: string;
browser: BrowserHealth;
providers: ProviderHealth[];
}
interface RotateIpResp {
ok: boolean;
new_ip: string | null;
reason: string | null;
}
// ── Hooks ──────────────────────────────────────────────────────────────────
export function useScraperHealth() {
return useQuery<ScraperHealthResp>({
queryKey: ["scraper-health"],
queryFn: () => apiFetch<ScraperHealthResp>("/api/v1/admin/scraper/health"),
refetchInterval: 30_000,
staleTime: 25_000,
retry: 1,
});
}
export function useRotateIp(source: string) {
const qc = useQueryClient();
return useMutation<RotateIpResp, Error>({
mutationFn: () =>
apiFetch<RotateIpResp>(
`/api/v1/admin/scraper/${encodeURIComponent(source)}/rotate-ip`,
{ method: "POST" },
),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["scraper-health"] });
},
});
}
// ── SystemSection ──────────────────────────────────────────────────────────
function FetchModeBadge({ mode }: { mode: string }) {
const isBrowser = mode === "browser";
return (
<span
style={{
display: "inline-block",
padding: "2px 8px",
borderRadius: 6,
fontSize: "0.8rem",
fontWeight: 600,
background: isBrowser
? "var(--accent-soft, #dbeafe)"
: "var(--warn-soft, #fef3c7)",
color: isBrowser
? "var(--accent, #1d4ed8)"
: "var(--warn, #9a6700)",
fontFamily: "ui-monospace, monospace",
}}
>
{mode}
</span>
);
}
function BrowserBadge({ reachable }: { reachable: boolean }) {
return (
<span
style={{
display: "inline-block",
padding: "2px 8px",
borderRadius: 6,
fontSize: "0.8rem",
fontWeight: 600,
background: reachable
? "var(--success-soft, #dcfce7)"
: "var(--danger-soft, #fee2e2)",
color: reachable
? "var(--success, #0a7a3a)"
: "var(--danger, #b3261e)",
}}
>
{reachable ? "доступен" : "недоступен"}
</span>
);
}
interface ProviderRowProps {
provider: ProviderHealth;
}
function ProviderProxyRow({ provider }: ProviderRowProps) {
const rotateMut = useRotateIp(provider.source);
const proxyStr =
provider.proxy_host && provider.proxy_port
? `${provider.proxy_host}:${provider.proxy_port}`
: "—";
return (
<tr>
<td>
<code style={{ fontWeight: 600 }}>{provider.source}</code>
</td>
<td>
<code style={{ fontSize: "0.8rem" }}>{proxyStr}</code>
</td>
<td>
<code style={{ fontSize: "0.8rem" }}>
{provider.current_ip ?? "—"}
</code>
</td>
<td>
<span
style={{
fontSize: "0.8rem",
color: "var(--fg-secondary, #5b6066)",
}}
>
{provider.rotate_supported ? "rotate" : "статический"}
</span>
</td>
<td>
{provider.rotate_supported ? (
<button
type="button"
className="cancel-btn"
disabled={rotateMut.isPending}
onClick={() => rotateMut.mutate()}
style={{ fontSize: "0.8rem", padding: "3px 10px" }}
>
{rotateMut.isPending ? "…" : "Сменить IP"}
</button>
) : (
<span
style={{
fontSize: "0.8rem",
color: "var(--fg-tertiary, #73767e)",
}}
>
</span>
)}
{rotateMut.isSuccess && rotateMut.data.ok && (
<span
style={{
marginLeft: 8,
fontSize: "0.75rem",
color: "var(--success, #0a7a3a)",
}}
>
{rotateMut.data.new_ip ?? "обновлён"}
</span>
)}
{rotateMut.isSuccess && !rotateMut.data.ok && (
<span
style={{
marginLeft: 8,
fontSize: "0.75rem",
color: "var(--danger, #b3261e)",
}}
>
{rotateMut.data.reason ?? "ошибка"}
</span>
)}
{rotateMut.isError && (
<span
style={{
marginLeft: 8,
fontSize: "0.75rem",
color: "var(--danger, #b3261e)",
}}
>
{rotateMut.error.message}
</span>
)}
</td>
</tr>
);
}
export function SystemHealthSection() {
const healthQ = useScraperHealth();
return (
<section className="scraper-section scraper-section--highlight">
<h2>Система</h2>
<p className="scraper-hint">
Статус fetch-режима, browser-сервиса и прокси провайдеров.
Автообновление каждые 30 сек.
</p>
{healthQ.isPending && (
<p className="scraper-hint">Загрузка состояния системы</p>
)}
{healthQ.isError && (
<p
className="scraper-result scraper-result--error"
style={{ marginBottom: 12 }}
>
Ошибка загрузки health: {healthQ.error.message}
{" — "}
<span style={{ color: "var(--fg-secondary, #5b6066)" }}>
API /scraper/health ещё не задеплоен (будет после merge backend PR)
</span>
</p>
)}
{healthQ.data && (
<>
{/* Fetch mode + browser status row */}
<div
style={{
display: "flex",
gap: 24,
alignItems: "center",
marginBottom: 16,
flexWrap: "wrap",
}}
>
<div
style={{ display: "flex", alignItems: "center", gap: 8 }}
>
<span
style={{
fontSize: "0.85rem",
color: "var(--fg-secondary, #5b6066)",
}}
>
Режим:
</span>
<FetchModeBadge mode={healthQ.data.fetch_mode} />
</div>
<div
style={{ display: "flex", alignItems: "center", gap: 8 }}
>
<span
style={{
fontSize: "0.85rem",
color: "var(--fg-secondary, #5b6066)",
}}
>
Browser-сервис:
</span>
<BrowserBadge
reachable={healthQ.data.browser.reachable}
/>
</div>
{Object.entries(healthQ.data.browser.browsers).map(
([name, ok]) => (
<div
key={name}
style={{ display: "flex", alignItems: "center", gap: 4 }}
>
<span
style={{
fontSize: "0.8rem",
color: "var(--fg-secondary, #5b6066)",
}}
>
{name}:
</span>
<span
style={{
width: 8,
height: 8,
borderRadius: "50%",
display: "inline-block",
background: ok
? "var(--success, #0a7a3a)"
: "var(--danger, #b3261e)",
}}
/>
</div>
),
)}
</div>
{/* Per-provider proxy table */}
{healthQ.data.providers.length > 0 && (
<table className="runs-table">
<thead>
<tr>
<th>Провайдер</th>
<th>Прокси (host:port)</th>
<th>Текущий IP</th>
<th>Режим</th>
<th>Действие</th>
</tr>
</thead>
<tbody>
{healthQ.data.providers.map((p) => (
<ProviderProxyRow key={p.source} provider={p} />
))}
</tbody>
</table>
)}
</>
)}
</section>
);
}

View file

@ -0,0 +1,329 @@
"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>
);
}

View file

@ -0,0 +1,320 @@
"use client";
import { useEffect, useState } from "react";
import {
formatTime,
useSchedules,
useStartCitySweep,
useUpdateSchedule,
utcToMsk,
mskToUtc,
type ScheduleSourceKey,
type ScraperSource,
type SweepParamConfig,
} from "@/app/scrapers/_components/ScraperPage";
// ── ScheduleControl ────────────────────────────────────────────────────────
interface ScheduleControlProps {
source: ScraperSource;
scheduleSource: ScheduleSourceKey;
paramConfig: SweepParamConfig;
}
export function ScheduleControl({
source,
scheduleSource,
paramConfig,
}: ScheduleControlProps) {
const schedulesQ = useSchedules();
const updateScheduleMut = useUpdateSchedule();
const sweepMut = useStartCitySweep(source);
const [schEnabled, setSchEnabled] = useState(true);
const [schMskStart, setSchMskStart] = useState(5);
const [schMskEnd, setSchMskEnd] = useState(8);
const [schPages, setSchPages] = useState(
paramConfig.defaults.pages_per_anchor,
);
const [schTopN, setSchTopN] = useState(
paramConfig.defaults.detail_top_n ?? 0,
);
const [schDelay, setSchDelay] = useState(
paramConfig.defaults.request_delay_sec,
);
const [schRadius, setSchRadius] = useState(paramConfig.defaults.radius_m);
const [schEnrichHouses, setSchEnrichHouses] = useState(
paramConfig.defaults.enrich_houses ?? false,
);
const [schEnrichAddress, setSchEnrichAddress] = useState(
paramConfig.defaults.enrich_address ?? false,
);
useEffect(() => {
const sweep = schedulesQ.data?.find((s) => s.source === scheduleSource);
if (!sweep) return;
setSchEnabled(sweep.enabled);
setSchMskStart(utcToMsk(sweep.window_start_hour));
setSchMskEnd(utcToMsk(sweep.window_end_hour));
const p = sweep.default_params ?? {};
if (typeof p.pages_per_anchor === "number")
setSchPages(p.pages_per_anchor);
if (typeof p.detail_top_n === "number") setSchTopN(p.detail_top_n);
if (typeof p.request_delay_sec === "number")
setSchDelay(p.request_delay_sec);
if (typeof p.radius_m === "number") setSchRadius(p.radius_m);
if (typeof p.enrich_houses === "boolean")
setSchEnrichHouses(p.enrich_houses);
if (typeof p.enrich_address === "boolean")
setSchEnrichAddress(p.enrich_address);
}, [schedulesQ.data, scheduleSource]);
const currentSweep = schedulesQ.data?.find(
(s) => s.source === scheduleSource,
);
function handleSweepTrigger() {
const body: Record<string, unknown> = {
pages_per_anchor: schPages,
request_delay_sec: schDelay,
radius_m: schRadius,
};
if (paramConfig.hasDetailParams) {
body.detail_top_n = schTopN;
body.enrich_houses = schEnrichHouses;
}
if (paramConfig.hasEnrichAddress) {
body.enrich_address = schEnrichAddress;
}
sweepMut.mutate(body);
}
return (
<section className="scraper-section scraper-section--schedule">
<h2>Расписание и ручной запуск</h2>
{schedulesQ.isPending && (
<p className="scraper-hint">Загрузка расписания</p>
)}
{schedulesQ.isError && (
<p className="scraper-result scraper-result--error">
Ошибка загрузки: {schedulesQ.error.message}
</p>
)}
{/* Current status */}
{currentSweep && (
<div className="schedule-status" style={{ marginBottom: 16 }}>
<div className="schedule-status__row">
<span className="schedule-status__label">Статус:</span>
<span
className={`run-badge run-badge--${currentSweep.enabled ? "running" : "cancelled"}`}
>
{currentSweep.enabled ? "включено" : "отключено"}
</span>
</div>
<div className="schedule-status__row">
<span className="schedule-status__label">Окно (МСК):</span>
<span>
{String(utcToMsk(currentSweep.window_start_hour)).padStart(
2,
"0",
)}
:00
{String(utcToMsk(currentSweep.window_end_hour)).padStart(
2,
"0",
)}
:00
</span>
</div>
<div className="schedule-status__row">
<span className="schedule-status__label">Следующий:</span>
<span>{formatTime(currentSweep.next_run_at)}</span>
</div>
<div className="schedule-status__row">
<span className="schedule-status__label">Последний:</span>
<span>
{currentSweep.last_run_at
? formatTime(currentSweep.last_run_at)
: "—"}
{currentSweep.last_run_id &&
` (run #${currentSweep.last_run_id})`}
</span>
</div>
</div>
)}
{/* Schedule settings form */}
<form
className="scraper-form"
onSubmit={(e) => {
e.preventDefault();
const defaultParams: Record<string, unknown> = {
pages_per_anchor: schPages,
request_delay_sec: schDelay,
radius_m: schRadius,
};
if (paramConfig.hasDetailParams) {
defaultParams.detail_top_n = schTopN;
defaultParams.enrich_houses = schEnrichHouses;
}
if (paramConfig.hasEnrichAddress) {
defaultParams.enrich_address = schEnrichAddress;
}
updateScheduleMut.mutate({
source: scheduleSource,
payload: {
enabled: schEnabled,
window_start_hour: mskToUtc(schMskStart),
window_end_hour: mskToUtc(schMskEnd),
default_params: defaultParams,
},
});
}}
>
<label className="checkbox">
<input
type="checkbox"
checked={schEnabled}
onChange={(e) => setSchEnabled(e.target.checked)}
/>
Включено
</label>
<label>
Окно начала (МСК)
<input
type="number"
min={0}
max={23}
value={schMskStart}
onChange={(e) => setSchMskStart(parseInt(e.target.value, 10))}
/>
</label>
<label>
Окно окончания (МСК)
<input
type="number"
min={0}
max={23}
value={schMskEnd}
onChange={(e) => setSchMskEnd(parseInt(e.target.value, 10))}
/>
</label>
<label>
Pages/anchor
<input
type="number"
min={1}
max={10}
value={schPages}
onChange={(e) => setSchPages(parseInt(e.target.value, 10))}
/>
</label>
{paramConfig.hasDetailParams && (
<label>
Detail top-N
<input
type="number"
min={0}
max={30}
value={schTopN}
onChange={(e) => setSchTopN(parseInt(e.target.value, 10))}
/>
</label>
)}
<label>
Delay (сек)
<input
type="number"
step={0.5}
min={1}
max={60}
value={schDelay}
onChange={(e) => setSchDelay(parseFloat(e.target.value))}
/>
</label>
<label>
Радиус (м)
<input
type="number"
min={500}
max={5000}
step={100}
value={schRadius}
onChange={(e) => setSchRadius(parseInt(e.target.value, 10))}
/>
</label>
{paramConfig.hasDetailParams && (
<label className="checkbox">
<input
type="checkbox"
checked={schEnrichHouses}
onChange={(e) => setSchEnrichHouses(e.target.checked)}
/>
Enrich houses
</label>
)}
{paramConfig.hasEnrichAddress && (
<label className="checkbox">
<input
type="checkbox"
checked={schEnrichAddress}
onChange={(e) => setSchEnrichAddress(e.target.checked)}
/>
Enrich address
</label>
)}
<button type="submit" disabled={updateScheduleMut.isPending}>
{updateScheduleMut.isPending
? "Сохраняем…"
: "Сохранить расписание"}
</button>
</form>
{updateScheduleMut.isError && (
<div className="scraper-result scraper-result--error">
Ошибка: {updateScheduleMut.error.message}
</div>
)}
{updateScheduleMut.isSuccess && updateScheduleMut.data && (
<div className="scraper-result">
Расписание обновлено. Next run:{" "}
{formatTime(updateScheduleMut.data.next_run_at)}
</div>
)}
{/* Manual trigger */}
<div style={{ marginTop: 16, borderTop: "1px solid var(--border-soft, #eef0f3)", paddingTop: 16 }}>
<p
style={{
fontSize: "0.85rem",
color: "var(--fg-secondary, #5b6066)",
marginBottom: 10,
}}
>
Ручной запуск city sweep с параметрами выше:
</p>
<button
type="button"
onClick={handleSweepTrigger}
disabled={sweepMut.isPending}
>
{sweepMut.isPending ? "Запускаем…" : "Запустить sweep вручную"}
</button>
{sweepMut.isError && (
<div className="scraper-result scraper-result--error" style={{ marginTop: 8 }}>
Ошибка: {sweepMut.error.message}
</div>
)}
{sweepMut.isSuccess && sweepMut.data && (
<div className="scraper-result" style={{ marginTop: 8 }}>
<pre style={{ margin: 0, fontSize: "0.8rem" }}>
{JSON.stringify(sweepMut.data, null, 2)}
</pre>
</div>
)}
</div>
</section>
);
}

View file

@ -29,8 +29,7 @@ export type ActiveTab =
| "scrapers"
| "avito"
| "cian"
| "yandex"
| "cian-cookies";
| "yandex";
interface TopbarProps {
active: ActiveTab;
@ -86,12 +85,6 @@ const NAV_ITEMS: Array<{
scopePath: "/trade-in/api/v1/admin/scrapers/yandex",
label: "Яндекс",
},
{
key: "cian-cookies",
href: "/scrapers/cian-cookies",
scopePath: "/trade-in/api/v1/admin/scrapers/cian-cookies",
label: "Cian Cookies",
},
];
export function Topbar({ active }: TopbarProps) {