- cian/page.tsx: отдельный queryKey ["cian-test-auth", "manual"] для ручного testAuthQuery в CianCookiesSection — устраняет кросс-контаминацию с автопуллингом useTestAuth() (refetchInterval:60s) и ручной кнопкой "Test Current Session" - RunsTable.tsx, ScraperPage.tsx (RunsLogSection), ProxyHealthCard.tsx: scope="col" на все <th> в thead — соответствие WCAG SC 1.3.1 (Info and Relationships) - Topbar.tsx: aria-current="page" на активный nav-link вместо tablist/tabpanel — корректный паттерн для link-based навигации между отдельными страницами - ProxyHealthCard.tsx: role="img" + aria-label на цветовые dot-индикаторы статуса browser-инстансов — цвет больше не единственный носитель смысла (WCAG 1.4.1)
322 lines
9.2 KiB
TypeScript
322 lines
9.2 KiB
TypeScript
"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
|
||
role="img"
|
||
aria-label={ok ? "статус: доступен" : "статус: недоступен"}
|
||
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 scope="col">Провайдер</th>
|
||
<th scope="col">Прокси (host:port)</th>
|
||
<th scope="col">Текущий IP</th>
|
||
<th scope="col">Режим</th>
|
||
<th scope="col">Действие</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{healthQ.data.providers.map((p) => (
|
||
<ProviderProxyRow key={p.source} provider={p} />
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
)}
|
||
</>
|
||
)}
|
||
</section>
|
||
);
|
||
}
|