feat(tradein): /scrapers/avito — 6-я секция Расписание (in-app scheduler UI) #483

Merged
lekss361 merged 1 commit from feat/tradein-frontend-scheduler into main 2026-05-23 14:55:32 +00:00
2 changed files with 318 additions and 1 deletions

View file

@ -1,6 +1,6 @@
"use client";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import "@/components/trade-in/trade-in.css";
@ -203,6 +203,61 @@ function useCancelCitySweep() {
});
}
// ── Schedule types + hooks ─────────────────────────────────────────────────
interface ScheduleConfig {
id: number;
source: string;
enabled: boolean;
window_start_hour: number;
window_end_hour: number;
default_params: {
pages_per_anchor?: number;
detail_top_n?: number;
request_delay_sec?: number;
enrich_houses?: boolean;
radius_m?: number;
};
last_run_id: number | null;
last_run_at: string | null;
next_run_at: string | null;
updated_at: string | null;
}
interface ScheduleConfigUpdate {
enabled: boolean;
window_start_hour: number;
window_end_hour: number;
default_params: Record<string, unknown>;
}
function useSchedules() {
return useQuery<ScheduleConfig[]>({
queryKey: ["scrape-schedules"],
queryFn: () =>
apiFetch<ScheduleConfig[]>("/api/v1/admin/scrape/schedules"),
refetchInterval: 30_000,
});
}
function useUpdateSchedule() {
const qc = useQueryClient();
return useMutation<
ScheduleConfig,
Error,
{ source: string; payload: ScheduleConfigUpdate }
>({
mutationFn: ({ source, payload }) =>
apiFetch<ScheduleConfig>(`/api/v1/admin/scrape/schedules/${source}`, {
method: "PUT",
body: JSON.stringify(payload),
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["scrape-schedules"] });
},
});
}
// ── Result panel ───────────────────────────────────────────────────────────
interface ResultPanelProps<TData> {
@ -277,6 +332,33 @@ export default function AvitoScraperPage() {
const sweepRuns = useCitySweepRuns();
const cancelMut = useCancelCitySweep();
// Scheduler state
const schedulesQ = useSchedules();
const updateScheduleMut = useUpdateSchedule();
const [schEnabled, setSchEnabled] = useState(true);
const [schMskStart, setSchMskStart] = useState(5);
const [schMskEnd, setSchMskEnd] = useState(8);
const [schPages, setSchPages] = useState(3);
const [schTopN, setSchTopN] = useState(20);
const [schDelay, setSchDelay] = useState(7);
const [schRadius, setSchRadius] = useState(1500);
const [schEnrich, setSchEnrich] = useState(true);
useEffect(() => {
const sweep = schedulesQ.data?.find((s) => s.source === "avito_city_sweep");
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") setSchEnrich(p.enrich_houses);
}, [schedulesQ.data]);
return (
<>
<Topbar active="avito" />
@ -675,6 +757,194 @@ export default function AvitoScraperPage() {
)}
</div>
</section>
{/* 6. Расписание автозапуска */}
<section className="scraper-section scraper-section--schedule">
<h2>6. Расписание автозапуска (in-app scheduler)</h2>
<p className="scraper-hint">
Backend периодически (каждую минуту) проверяет это расписание и запускает
city sweep автоматически в случайное время в указанном окне. Без SSH, без
crontab всё в БД и UI.
</p>
{schedulesQ.isPending && <p className="scraper-hint">Загрузка</p>}
{schedulesQ.error && (
<p className="scraper-result scraper-result--error">
Ошибка загрузки: {schedulesQ.error.message}
</p>
)}
{schedulesQ.data && (
<>
{/* Status preview */}
{(() => {
const sweep = schedulesQ.data.find(
(s) => s.source === "avito_city_sweep",
);
if (!sweep) return null;
return (
<div className="schedule-status">
<div className="schedule-status__row">
<span className="schedule-status__label">Status:</span>
<span
className={`run-badge run-badge--${sweep.enabled ? "running" : "cancelled"}`}
>
{sweep.enabled ? "включено" : "отключено"}
</span>
</div>
<div className="schedule-status__row">
<span className="schedule-status__label">Окно (МСК):</span>
<span>
{formatMskRange(
sweep.window_start_hour,
sweep.window_end_hour,
)}
</span>
<span className="run-muted">
({String(sweep.window_start_hour).padStart(2, "0")}:00
{String(sweep.window_end_hour).padStart(2, "0")}:00 UTC)
</span>
</div>
<div className="schedule-status__row">
<span className="schedule-status__label">
Следующий запуск:
</span>
<span>{formatTime(sweep.next_run_at)}</span>
</div>
<div className="schedule-status__row">
<span className="schedule-status__label">
Последний запуск:
</span>
<span>
{sweep.last_run_at ? formatTime(sweep.last_run_at) : "—"}
{sweep.last_run_id && ` (run #${sweep.last_run_id})`}
</span>
</div>
</div>
);
})()}
{/* Edit form */}
<form
className="scraper-form"
onSubmit={(e) => {
e.preventDefault();
updateScheduleMut.mutate({
source: "avito_city_sweep",
payload: {
enabled: schEnabled,
window_start_hour: mskToUtc(schMskStart),
window_end_hour: mskToUtc(schMskEnd),
default_params: {
pages_per_anchor: schPages,
detail_top_n: schTopN,
request_delay_sec: schDelay,
enrich_houses: schEnrich,
radius_m: schRadius,
},
},
});
}}
>
<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>
<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={3}
max={15}
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>
<label className="checkbox">
<input
type="checkbox"
checked={schEnrich}
onChange={(e) => setSchEnrich(e.target.checked)}
/>
Enrich houses
</label>
<button type="submit" disabled={updateScheduleMut.isPending}>
{updateScheduleMut.isPending
? "Сохраняем…"
: "Сохранить расписание"}
</button>
</form>
{updateScheduleMut.error && (
<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>
)}
</>
)}
</section>
</main>
</>
);
@ -717,3 +987,17 @@ function formatTime(iso: string | null): string {
return iso;
}
}
function utcToMsk(hour: number): number {
return (hour + 3) % 24;
}
function mskToUtc(hour: number): number {
return (hour - 3 + 24) % 24;
}
function formatMskRange(utcStart: number, utcEnd: number): string {
const ms = utcToMsk(utcStart);
const me = utcToMsk(utcEnd);
return `${String(ms).padStart(2, "0")}:00${String(me).padStart(2, "0")}:00 МСК`;
}

View file

@ -1478,3 +1478,36 @@
background: var(--border-strong, #d1d5db);
cursor: not-allowed;
}
/* ── Stage 4e: scheduler section ── */
.scraper-section--schedule {
border-color: var(--success, #0a7a3a);
background: var(--success-soft, #dcfce7);
}
.scraper-section--schedule h2 {
color: var(--success, #0a7a3a);
}
.schedule-status {
margin-bottom: 16px;
padding: 12px;
background: #ffffff;
border-radius: 6px;
border: 1px solid var(--border-card, #e6e8ec);
}
.schedule-status__row {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
padding: 4px 0;
}
.schedule-status__label {
min-width: 140px;
color: var(--fg-secondary, #5b6066);
font-weight: 500;
}