Compare commits
No commits in common. "86f4602f1d345dc75d240e3e81b1e7759a66bbf9" and "3fa35be26e3af78379c7c457c113e052877c97d0" have entirely different histories.
86f4602f1d
...
3fa35be26e
7 changed files with 297 additions and 2511 deletions
21
tradein-mvp/frontend/src/app/scrapers/cian-cookies/page.tsx
Normal file
21
tradein-mvp/frontend/src/app/scrapers/cian-cookies/page.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
"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
|
|
@ -1,137 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,320 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,329 +0,0 @@
|
||||||
"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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,320 +0,0 @@
|
||||||
"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,7 +29,8 @@ export type ActiveTab =
|
||||||
| "scrapers"
|
| "scrapers"
|
||||||
| "avito"
|
| "avito"
|
||||||
| "cian"
|
| "cian"
|
||||||
| "yandex";
|
| "yandex"
|
||||||
|
| "cian-cookies";
|
||||||
|
|
||||||
interface TopbarProps {
|
interface TopbarProps {
|
||||||
active: ActiveTab;
|
active: ActiveTab;
|
||||||
|
|
@ -85,6 +86,12 @@ const NAV_ITEMS: Array<{
|
||||||
scopePath: "/trade-in/api/v1/admin/scrapers/yandex",
|
scopePath: "/trade-in/api/v1/admin/scrapers/yandex",
|
||||||
label: "Яндекс",
|
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) {
|
export function Topbar({ active }: TopbarProps) {
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue