gendesign/tradein-mvp/frontend/src/components/scrapers/ProxyHealthCard.tsx
bot-backend fa65c3f212
All checks were successful
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 6s
CI / openapi-codegen-check (pull_request) Has been skipped
fix(scrapers/ui): дедуп queryKey cian test-auth + a11y (th scope, табы ARIA, статус-точки aria-label)
- 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)
2026-06-20 10:14:58 +03:00

322 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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>
);
}