320 lines
10 KiB
TypeScript
320 lines
10 KiB
TypeScript
"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>
|
||
);
|
||
}
|