feat(tradein): /scrapers/avito — 6-я секция Расписание (in-app scheduler UI) (#483)
This commit is contained in:
parent
0202720096
commit
fd3cb4e6ed
2 changed files with 318 additions and 1 deletions
|
|
@ -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 МСК`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue