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
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:
commit
86f4602f1d
7 changed files with 2544 additions and 330 deletions
|
|
@ -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
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
320
tradein-mvp/frontend/src/components/scrapers/ProxyHealthCard.tsx
Normal file
320
tradein-mvp/frontend/src/components/scrapers/ProxyHealthCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
329
tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx
Normal file
329
tradein-mvp/frontend/src/components/scrapers/RunsTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
320
tradein-mvp/frontend/src/components/scrapers/ScheduleControl.tsx
Normal file
320
tradein-mvp/frontend/src/components/scrapers/ScheduleControl.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue