add NSPD
This commit is contained in:
parent
db6711446a
commit
78b8d7aa00
4 changed files with 1018 additions and 16 deletions
|
|
@ -358,6 +358,214 @@ def list_logs(
|
|||
]
|
||||
|
||||
|
||||
# ── NSPD (cad_quarters / cad_buildings) ─────────────────────────────────────
|
||||
|
||||
|
||||
class TriggerNspdRequest(BaseModel):
|
||||
region_code: int = Field(default=66, ge=1, le=99)
|
||||
limit: int | None = Field(default=None, ge=1, le=2000)
|
||||
rate_ms: int = Field(default=600, ge=200, le=5000)
|
||||
force: bool = False
|
||||
|
||||
|
||||
@router.post("/nspd")
|
||||
def trigger_nspd_sweep(
|
||||
payload: TriggerNspdRequest,
|
||||
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
|
||||
) -> dict[str, Any]:
|
||||
_check_token(x_admin_token)
|
||||
from app.workers.tasks.scrape_nspd import (
|
||||
force_release_lock as nspd_force_release_lock,
|
||||
)
|
||||
from app.workers.tasks.scrape_nspd import (
|
||||
scrape_nspd_region,
|
||||
)
|
||||
|
||||
lock_released = False
|
||||
if payload.force:
|
||||
lock_released = nspd_force_release_lock(payload.region_code)
|
||||
|
||||
result = scrape_nspd_region.apply_async(
|
||||
kwargs={
|
||||
"region_code": payload.region_code,
|
||||
"triggered_by": "manual",
|
||||
"limit": payload.limit,
|
||||
"rate_ms": payload.rate_ms,
|
||||
},
|
||||
)
|
||||
return {
|
||||
"task_id": result.id,
|
||||
"region_code": payload.region_code,
|
||||
"limit": payload.limit,
|
||||
"rate_ms": payload.rate_ms,
|
||||
"force": payload.force,
|
||||
"lock_was_released": lock_released,
|
||||
}
|
||||
|
||||
|
||||
class NspdReleaseLockRequest(BaseModel):
|
||||
region_code: int = Field(..., ge=1, le=99)
|
||||
|
||||
|
||||
@router.post("/nspd/release-lock")
|
||||
def nspd_release_lock(
|
||||
payload: NspdReleaseLockRequest,
|
||||
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
|
||||
) -> dict[str, Any]:
|
||||
_check_token(x_admin_token)
|
||||
from app.workers.tasks.scrape_nspd import force_release_lock as nspd_force_release_lock
|
||||
|
||||
released = nspd_force_release_lock(payload.region_code)
|
||||
return {"region_code": payload.region_code, "released": released}
|
||||
|
||||
|
||||
@router.get("/nspd/runs")
|
||||
def list_nspd_runs(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
|
||||
limit: int = 20,
|
||||
) -> list[dict[str, Any]]:
|
||||
_check_token(x_admin_token)
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT run_id, started_at, finished_at, heartbeat_at,
|
||||
region_code, pending_count,
|
||||
quarters_ok, quarters_failed, buildings_ok,
|
||||
requests_count, waf_429_count,
|
||||
status, error, triggered_by
|
||||
FROM nspd_scrape_runs
|
||||
ORDER BY started_at DESC
|
||||
LIMIT :lim
|
||||
"""
|
||||
),
|
||||
{"lim": min(limit, 100)},
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"run_id": r["run_id"],
|
||||
"started_at": r["started_at"].isoformat() if r["started_at"] else None,
|
||||
"finished_at": r["finished_at"].isoformat() if r["finished_at"] else None,
|
||||
"heartbeat_at": r["heartbeat_at"].isoformat() if r["heartbeat_at"] else None,
|
||||
"region_code": r["region_code"],
|
||||
"pending_count": r["pending_count"],
|
||||
"quarters_ok": r["quarters_ok"],
|
||||
"quarters_failed": r["quarters_failed"],
|
||||
"buildings_ok": r["buildings_ok"],
|
||||
"requests_count": r["requests_count"],
|
||||
"waf_429_count": r["waf_429_count"],
|
||||
"status": r["status"],
|
||||
"error": r["error"],
|
||||
"triggered_by": r["triggered_by"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/nspd/logs")
|
||||
def list_nspd_logs(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
|
||||
run_id: int | None = None,
|
||||
since_id: int | None = None,
|
||||
limit: int = 200,
|
||||
) -> list[dict[str, Any]]:
|
||||
_check_token(x_admin_token)
|
||||
where: list[str] = []
|
||||
params: dict[str, Any] = {"lim": min(limit, 1000)}
|
||||
if run_id is not None:
|
||||
where.append("run_id = :rid")
|
||||
params["rid"] = run_id
|
||||
if since_id is not None:
|
||||
where.append("log_id > :sid")
|
||||
params["sid"] = since_id
|
||||
where_sql = ("WHERE " + " AND ".join(where)) if where else ""
|
||||
rows = (
|
||||
db.execute(
|
||||
text(
|
||||
f"""
|
||||
SELECT log_id, run_id, ts, level, stage, cad_number, message
|
||||
FROM nspd_scrape_log
|
||||
{where_sql}
|
||||
ORDER BY log_id DESC
|
||||
LIMIT :lim
|
||||
"""
|
||||
),
|
||||
params,
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
return [
|
||||
{
|
||||
"log_id": r["log_id"],
|
||||
"run_id": r["run_id"],
|
||||
"ts": r["ts"].isoformat() if r["ts"] else None,
|
||||
"level": r["level"],
|
||||
"stage": r["stage"],
|
||||
"cad_number": r["cad_number"],
|
||||
"message": r["message"],
|
||||
}
|
||||
for r in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/nspd/coverage")
|
||||
def nspd_coverage(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
x_admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
|
||||
region_code: int = 66,
|
||||
) -> dict[str, Any]:
|
||||
"""Покрытие cad_quarters_geom относительно ДДУ-сделок rosreestr.
|
||||
Полезно перед запуском sweep — видишь сколько ещё осталось скрейпить."""
|
||||
_check_token(x_admin_token)
|
||||
row = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
WITH pending AS (
|
||||
SELECT DISTINCT quarter_cad_number AS cad
|
||||
FROM rosreestr_deals
|
||||
WHERE region_code = :rc
|
||||
AND doc_type = 'ДДУ'
|
||||
AND realestate_type_code = '002001003000'
|
||||
AND quarter_cad_number IS NOT NULL
|
||||
AND quarter_cad_number !~ '^00:00:'
|
||||
AND quarter_cad_number !~ ':0000000$'
|
||||
),
|
||||
done AS (SELECT cad_number AS cad FROM cad_quarters_geom)
|
||||
SELECT
|
||||
(SELECT COUNT(*) FROM pending) AS total_with_ddu,
|
||||
(SELECT COUNT(*) FROM pending p
|
||||
WHERE EXISTS (SELECT 1 FROM done d WHERE d.cad = p.cad))
|
||||
AS already_scraped,
|
||||
(SELECT COUNT(*) FROM cad_buildings) AS buildings_total
|
||||
"""
|
||||
),
|
||||
{"rc": region_code},
|
||||
)
|
||||
.mappings()
|
||||
.first()
|
||||
)
|
||||
total = int(row["total_with_ddu"] or 0)
|
||||
done = int(row["already_scraped"] or 0)
|
||||
return {
|
||||
"region_code": region_code,
|
||||
"total_quarters_with_ddu": total,
|
||||
"already_scraped": done,
|
||||
"pending": max(total - done, 0),
|
||||
"buildings_total": int(row["buildings_total"] or 0),
|
||||
"coverage_pct": round(done / max(total, 1) * 100, 1) if total else None,
|
||||
}
|
||||
|
||||
|
||||
# ── DomRF (kn) — runs list ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@router.get("/runs")
|
||||
def list_runs(
|
||||
db: Annotated[Session, Depends(get_db)],
|
||||
|
|
|
|||
|
|
@ -149,25 +149,46 @@ def _resume_zombie_runs(sender=None, **_kwargs) -> None:
|
|||
logger.warning("worker_ready: failed to enqueue resume for run=%s: %s", rid, e)
|
||||
|
||||
# NSPD-runs: помечаем зомби (>15 мин без heartbeat — NSPD сам по себе
|
||||
# медленнее kn, дольше дельта). Resume не делаем — следующий beat
|
||||
# подберёт pending (idempotent через cad_quarters_geom).
|
||||
# медленнее kn, дельта больше). Resume = просто заново ставим
|
||||
# scrape_nspd_region с тем же region_code и triggered_by='resume':
|
||||
# сервис идемпотентен — pending = (rosreestr ДДУ cads) − (cad_quarters_geom done),
|
||||
# уже скрейпнутые квартала пропустятся автоматом.
|
||||
db = SessionLocal()
|
||||
nspd_resume_targets: list[int] = []
|
||||
try:
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE nspd_scrape_runs
|
||||
SET status = 'zombie',
|
||||
finished_at = NOW(),
|
||||
error = COALESCE(error,
|
||||
'auto-zombie at worker_ready')
|
||||
WHERE status = 'running'
|
||||
AND COALESCE(heartbeat_at, started_at)
|
||||
< NOW() - INTERVAL '15 minutes'
|
||||
"""
|
||||
zombie_rows = (
|
||||
db.execute(
|
||||
text(
|
||||
"""
|
||||
UPDATE nspd_scrape_runs
|
||||
SET status = 'zombie',
|
||||
finished_at = NOW(),
|
||||
error = COALESCE(error,
|
||||
'auto-zombie at worker_ready, resume scheduled')
|
||||
WHERE status = 'running'
|
||||
AND COALESCE(heartbeat_at, started_at)
|
||||
< NOW() - INTERVAL '15 minutes'
|
||||
RETURNING run_id, region_code
|
||||
"""
|
||||
)
|
||||
)
|
||||
.mappings()
|
||||
.all()
|
||||
)
|
||||
db.commit()
|
||||
# Уникальные регионы для resume (разные ран-id для одного региона —
|
||||
# повторно не enqueueum, lock и idempotency пропустят дубль).
|
||||
seen: set[int] = set()
|
||||
for r in zombie_rows:
|
||||
rc = int(r["region_code"])
|
||||
if rc not in seen:
|
||||
seen.add(rc)
|
||||
nspd_resume_targets.append(rc)
|
||||
logger.info(
|
||||
"worker_ready: NSPD zombie run=%s region=%s — resume scheduled",
|
||||
r["run_id"],
|
||||
rc,
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("worker_ready nspd zombie scan failed: %s", e)
|
||||
try:
|
||||
|
|
@ -176,3 +197,19 @@ def _resume_zombie_runs(sender=None, **_kwargs) -> None:
|
|||
pass
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if nspd_resume_targets:
|
||||
from app.workers.tasks.scrape_nspd import scrape_nspd_region
|
||||
|
||||
for rc in nspd_resume_targets:
|
||||
try:
|
||||
scrape_nspd_region.apply_async(
|
||||
kwargs={"region_code": rc, "triggered_by": "resume"},
|
||||
)
|
||||
logger.info("worker_ready: scrape_nspd_region enqueued region=%s", rc)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"worker_ready: failed to enqueue NSPD resume for region=%s: %s",
|
||||
rc,
|
||||
e,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -4,7 +4,8 @@ import Link from "next/link";
|
|||
import { usePathname } from "next/navigation";
|
||||
|
||||
const TABS = [
|
||||
{ href: "/admin/scrape", label: "Скрапер" },
|
||||
{ href: "/admin/scrape", label: "Скрапер DOM.РФ" },
|
||||
{ href: "/admin/scrape/nspd", label: "Скрапер NSPD" },
|
||||
{ href: "/admin/leads", label: "Лиды PRINZIP" },
|
||||
];
|
||||
|
||||
|
|
@ -35,8 +36,19 @@ export default function AdminLayout({
|
|||
}}
|
||||
>
|
||||
{TABS.map((t) => {
|
||||
const active =
|
||||
// Активна вкладка с самым длинным совпадающим префиксом —
|
||||
// иначе /admin/scrape тоже подсветится при заходе на
|
||||
// /admin/scrape/nspd.
|
||||
const matchesPrefix =
|
||||
pathname === t.href || pathname?.startsWith(t.href + "/");
|
||||
const longerMatch = TABS.some(
|
||||
(other) =>
|
||||
other.href !== t.href &&
|
||||
other.href.length > t.href.length &&
|
||||
(pathname === other.href ||
|
||||
pathname?.startsWith(other.href + "/")),
|
||||
);
|
||||
const active = matchesPrefix && !longerMatch;
|
||||
return (
|
||||
<Link
|
||||
key={t.href}
|
||||
|
|
|
|||
745
frontend/src/app/admin/scrape/nspd/page.tsx
Normal file
745
frontend/src/app/admin/scrape/nspd/page.tsx
Normal file
|
|
@ -0,0 +1,745 @@
|
|||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiFetch } from "@/lib/api";
|
||||
|
||||
interface NspdRunRow {
|
||||
run_id: number;
|
||||
started_at: string | null;
|
||||
finished_at: string | null;
|
||||
heartbeat_at: string | null;
|
||||
region_code: number;
|
||||
pending_count: number | null;
|
||||
quarters_ok: number;
|
||||
quarters_failed: number;
|
||||
buildings_ok: number;
|
||||
requests_count: number;
|
||||
waf_429_count: number;
|
||||
status: string;
|
||||
error: string | null;
|
||||
triggered_by: string;
|
||||
}
|
||||
|
||||
interface NspdLogRow {
|
||||
log_id: number;
|
||||
run_id: number | null;
|
||||
ts: string | null;
|
||||
level: "debug" | "info" | "warn" | "error";
|
||||
stage: string | null;
|
||||
cad_number: string | null;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface NspdCoverage {
|
||||
region_code: number;
|
||||
total_quarters_with_ddu: number;
|
||||
already_scraped: number;
|
||||
pending: number;
|
||||
buildings_total: number;
|
||||
coverage_pct: number | null;
|
||||
}
|
||||
|
||||
interface NspdTriggerResp {
|
||||
task_id: string;
|
||||
region_code: number;
|
||||
limit: number | null;
|
||||
rate_ms: number;
|
||||
force: boolean;
|
||||
lock_was_released: boolean;
|
||||
}
|
||||
|
||||
export default function NspdScrapeAdminPage() {
|
||||
const [token, setToken] = useState<string>(() =>
|
||||
typeof window === "undefined"
|
||||
? ""
|
||||
: (localStorage.getItem("admin_token") ?? ""),
|
||||
);
|
||||
const [region, setRegion] = useState(66);
|
||||
const [limit, setLimit] = useState<string>("");
|
||||
const [rateMs, setRateMs] = useState(600);
|
||||
const [forceRun, setForceRun] = useState(false);
|
||||
const [logsRunId, setLogsRunId] = useState<number | "">("");
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const saveToken = (v: string) => {
|
||||
setToken(v);
|
||||
localStorage.setItem("admin_token", v);
|
||||
};
|
||||
|
||||
const coverage = useQuery({
|
||||
queryKey: ["nspd-coverage", region, token],
|
||||
queryFn: () =>
|
||||
apiFetch<NspdCoverage>(
|
||||
`/api/v1/admin/scrape/nspd/coverage?region_code=${region}`,
|
||||
{
|
||||
headers: { "X-Admin-Token": token },
|
||||
},
|
||||
),
|
||||
enabled: !!token,
|
||||
refetchInterval: 10000,
|
||||
});
|
||||
|
||||
const runs = useQuery({
|
||||
queryKey: ["nspd-runs", token],
|
||||
queryFn: () =>
|
||||
apiFetch<NspdRunRow[]>("/api/v1/admin/scrape/nspd/runs?limit=20", {
|
||||
headers: { "X-Admin-Token": token },
|
||||
}),
|
||||
enabled: !!token,
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
|
||||
const logs = useQuery({
|
||||
queryKey: ["nspd-logs", logsRunId, token],
|
||||
queryFn: () => {
|
||||
const q =
|
||||
logsRunId !== "" ? `?run_id=${logsRunId}&limit=200` : "?limit=200";
|
||||
return apiFetch<NspdLogRow[]>(`/api/v1/admin/scrape/nspd/logs${q}`, {
|
||||
headers: { "X-Admin-Token": token },
|
||||
});
|
||||
},
|
||||
enabled: !!token,
|
||||
refetchInterval: 3000,
|
||||
});
|
||||
|
||||
const trigger = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch<NspdTriggerResp>("/api/v1/admin/scrape/nspd", {
|
||||
method: "POST",
|
||||
headers: { "X-Admin-Token": token },
|
||||
body: JSON.stringify({
|
||||
region_code: region,
|
||||
limit: limit === "" ? null : Number(limit),
|
||||
rate_ms: rateMs,
|
||||
force: forceRun,
|
||||
}),
|
||||
}),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["nspd-runs"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["nspd-coverage"] });
|
||||
},
|
||||
});
|
||||
|
||||
const releaseLock = useMutation({
|
||||
mutationFn: () =>
|
||||
apiFetch<{ region_code: number; released: boolean }>(
|
||||
"/api/v1/admin/scrape/nspd/release-lock",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "X-Admin-Token": token },
|
||||
body: JSON.stringify({ region_code: region }),
|
||||
},
|
||||
),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["nspd-runs"] });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<h2 style={{ marginTop: 0, fontSize: 18 }}>
|
||||
Скрапер NSPD — кадастровые кварталы и здания
|
||||
</h2>
|
||||
<p style={{ color: "#5b6066", fontSize: 13, marginTop: 4 }}>
|
||||
Берёт quarter_cad_number из rosreestr_deals (ДДУ-сделки на квартиры) →
|
||||
тянет полигон cad-квартала + здания внутри из NSPD geoportal. Расписание
|
||||
(beat): 20-е число фев/мая/авг/нояб (после квартальных публикаций
|
||||
rosreestr). См. <code>SCRAPE_NSPD_CRON</code>.
|
||||
</p>
|
||||
|
||||
<section style={cardStyle}>
|
||||
<label style={{ display: "block", marginBottom: 12 }}>
|
||||
<span style={labelStyle}>Admin Token</span>
|
||||
<input
|
||||
type="password"
|
||||
value={token}
|
||||
onChange={(e) => saveToken(e.target.value)}
|
||||
placeholder="SCRAPE_ADMIN_TOKEN"
|
||||
style={inputStyle}
|
||||
/>
|
||||
</label>
|
||||
<div style={{ display: "flex", gap: 16, flexWrap: "wrap" }}>
|
||||
<label>
|
||||
<span style={labelStyle}>Регион</span>
|
||||
<input
|
||||
type="number"
|
||||
value={region}
|
||||
min={1}
|
||||
max={99}
|
||||
onChange={(e) => setRegion(Number(e.target.value))}
|
||||
style={{ ...inputStyle, width: 100 }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span style={labelStyle}>Limit (smoke)</span>
|
||||
<input
|
||||
type="number"
|
||||
value={limit}
|
||||
onChange={(e) => setLimit(e.target.value)}
|
||||
placeholder="напр. 10"
|
||||
min={1}
|
||||
max={2000}
|
||||
style={{ ...inputStyle, width: 120 }}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span style={labelStyle}>Rate (мс)</span>
|
||||
<input
|
||||
type="number"
|
||||
value={rateMs}
|
||||
onChange={(e) => setRateMs(Number(e.target.value))}
|
||||
min={200}
|
||||
max={5000}
|
||||
step={100}
|
||||
style={{ ...inputStyle, width: 110 }}
|
||||
title="Пауза между NSPD-запросами. <600мс = WAF 403 риск"
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
marginTop: 22,
|
||||
}}
|
||||
title="Перед запуском удалить existing Redis-lock"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={forceRun}
|
||||
onChange={(e) => setForceRun(e.target.checked)}
|
||||
/>
|
||||
<span style={{ color: forceRun ? "#b3261e" : "inherit" }}>
|
||||
⚡ Force (снять lock)
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Pre-launch summary: сколько кварталов реально пойдёт в обработку
|
||||
и сколько примерно займёт. Считаем тут, а не на бэке — дешевле. */}
|
||||
{token && coverage.data
|
||||
? (() => {
|
||||
const limitNum = limit === "" ? null : Number(limit);
|
||||
const willProcess = limitNum
|
||||
? Math.min(limitNum, coverage.data.pending)
|
||||
: coverage.data.pending;
|
||||
// 2 запроса на квартал (полигон + здания) × rate_ms, +1с на запись
|
||||
const etaSec = willProcess * ((rateMs * 2) / 1000 + 1);
|
||||
const etaMin = Math.round(etaSec / 60);
|
||||
const etaH = Math.floor(etaMin / 60);
|
||||
const etaText =
|
||||
etaMin >= 60 ? `~${etaH}ч ${etaMin % 60}мин` : `~${etaMin} мин`;
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 14,
|
||||
padding: 10,
|
||||
background: willProcess === 0 ? "#f0fdf4" : "#eff6ff",
|
||||
border: `1px solid ${
|
||||
willProcess === 0 ? "#bbf7d0" : "#bfdbfe"
|
||||
}`,
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
>
|
||||
{willProcess === 0 ? (
|
||||
<>
|
||||
✅ Всё уже скрейпнуто для региона {region}. Запускать
|
||||
бесполезно — pending=0.
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
При запуске будет обработано{" "}
|
||||
<strong>
|
||||
{willProcess.toLocaleString("ru")} кварталов
|
||||
</strong>{" "}
|
||||
{limitNum && limitNum < coverage.data.pending ? (
|
||||
<span style={{ color: "#5b6066" }}>
|
||||
(limit {limitNum} из {coverage.data.pending} pending)
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: "#5b6066" }}>
|
||||
(вся очередь pending)
|
||||
</span>
|
||||
)}
|
||||
. Расчётное время: <strong>{etaText}</strong> при rate{" "}
|
||||
{rateMs}мс ({((rateMs * 2) / 1000).toFixed(1)}с / квартал
|
||||
× 2 запроса).
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
: null}
|
||||
|
||||
<div
|
||||
style={{ marginTop: 16, display: "flex", gap: 12, flexWrap: "wrap" }}
|
||||
>
|
||||
<button
|
||||
onClick={() => trigger.mutate()}
|
||||
disabled={
|
||||
!token || trigger.isPending || (coverage.data?.pending ?? 0) === 0
|
||||
}
|
||||
style={primaryBtn}
|
||||
>
|
||||
{trigger.isPending ? "Запускаю…" : "Запустить sweep"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm(
|
||||
"Снять Redis-lock для NSPD? Используй когда подозреваешь зомби-worker.",
|
||||
)
|
||||
) {
|
||||
releaseLock.mutate();
|
||||
}
|
||||
}}
|
||||
disabled={!token || releaseLock.isPending}
|
||||
style={secondaryBtn}
|
||||
>
|
||||
{releaseLock.isPending ? "Снимаю…" : "🔓 Снять Redis-lock"}
|
||||
</button>
|
||||
{!token ? (
|
||||
<span
|
||||
style={{
|
||||
marginLeft: 12,
|
||||
color: "#9a6700",
|
||||
fontSize: 13,
|
||||
alignSelf: "center",
|
||||
}}
|
||||
>
|
||||
← введите Admin Token выше
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{trigger.isError ? (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: 12,
|
||||
background: "#fef2f2",
|
||||
border: "1px solid #fecaca",
|
||||
borderRadius: 6,
|
||||
color: "#991b1b",
|
||||
fontSize: 14,
|
||||
wordBreak: "break-word",
|
||||
}}
|
||||
>
|
||||
<strong>Ошибка:</strong> {String(trigger.error)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{trigger.isSuccess ? (
|
||||
<div
|
||||
style={{
|
||||
marginTop: 12,
|
||||
padding: 12,
|
||||
background: "#f0fdf4",
|
||||
border: "1px solid #bbf7d0",
|
||||
borderRadius: 6,
|
||||
color: "#065f46",
|
||||
fontSize: 14,
|
||||
}}
|
||||
>
|
||||
<strong>✓ Поставлено в очередь.</strong> task_id={" "}
|
||||
<code
|
||||
style={{
|
||||
background: "#dcfce7",
|
||||
padding: "2px 6px",
|
||||
borderRadius: 3,
|
||||
}}
|
||||
>
|
||||
{trigger.data.task_id}
|
||||
</code>{" "}
|
||||
· регион {trigger.data.region_code}
|
||||
{trigger.data.limit ? ` · limit=${trigger.data.limit}` : ""} · rate=
|
||||
{trigger.data.rate_ms}мс
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section style={{ ...cardStyle, marginTop: 16 }}>
|
||||
<h2 style={{ margin: 0, fontSize: 16, marginBottom: 12 }}>
|
||||
Покрытие cad_quarters_geom (регион {region})
|
||||
</h2>
|
||||
{!token ? (
|
||||
<p style={{ color: "#5b6066", fontSize: 13 }}>
|
||||
Введите Admin Token выше.
|
||||
</p>
|
||||
) : coverage.isLoading ? (
|
||||
<p style={{ color: "#5b6066" }}>Загрузка…</p>
|
||||
) : coverage.isError ? (
|
||||
<p style={{ color: "#b3261e" }}>Ошибка: {String(coverage.error)}</p>
|
||||
) : coverage.data ? (
|
||||
<div
|
||||
style={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: "repeat(auto-fit, minmax(160px, 1fr))",
|
||||
gap: 12,
|
||||
}}
|
||||
>
|
||||
<CoverageCard
|
||||
label="ДДУ-кварталов в rosreestr"
|
||||
value={coverage.data.total_quarters_with_ddu.toLocaleString("ru")}
|
||||
/>
|
||||
<CoverageCard
|
||||
label="Уже скрейпнуто"
|
||||
value={coverage.data.already_scraped.toLocaleString("ru")}
|
||||
color="#0a7a3a"
|
||||
/>
|
||||
<CoverageCard
|
||||
label="Осталось"
|
||||
value={coverage.data.pending.toLocaleString("ru")}
|
||||
color={coverage.data.pending > 0 ? "#9a6700" : "#0a7a3a"}
|
||||
/>
|
||||
<CoverageCard
|
||||
label="Зданий в БД"
|
||||
value={coverage.data.buildings_total.toLocaleString("ru")}
|
||||
/>
|
||||
<CoverageCard
|
||||
label="Покрытие, %"
|
||||
value={
|
||||
coverage.data.coverage_pct != null
|
||||
? `${coverage.data.coverage_pct}%`
|
||||
: "—"
|
||||
}
|
||||
color={
|
||||
coverage.data.coverage_pct != null &&
|
||||
coverage.data.coverage_pct >= 90
|
||||
? "#0a7a3a"
|
||||
: "#9a6700"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
<section style={{ ...cardStyle, marginTop: 16 }}>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
justifyContent: "space-between",
|
||||
marginBottom: 12,
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: 0, fontSize: 16 }}>
|
||||
Лог скрейпа{" "}
|
||||
<span style={{ fontWeight: 400, color: "#5b6066", fontSize: 13 }}>
|
||||
· обновляется каждые 3 сек
|
||||
</span>
|
||||
</h2>
|
||||
<input
|
||||
type="number"
|
||||
value={logsRunId}
|
||||
onChange={(e) =>
|
||||
setLogsRunId(e.target.value === "" ? "" : Number(e.target.value))
|
||||
}
|
||||
placeholder="run_id (опц.)"
|
||||
style={{ ...inputStyle, width: 130 }}
|
||||
/>
|
||||
</div>
|
||||
{!token ? (
|
||||
<p style={{ color: "#5b6066", fontSize: 13 }}>
|
||||
Введите Admin Token чтобы видеть лог.
|
||||
</p>
|
||||
) : logs.isLoading ? (
|
||||
<p style={{ color: "#5b6066" }}>Загрузка…</p>
|
||||
) : logs.isError ? (
|
||||
<p style={{ color: "#b3261e" }}>Ошибка: {String(logs.error)}</p>
|
||||
) : (logs.data?.length ?? 0) === 0 ? (
|
||||
<p style={{ color: "#5b6066", fontSize: 13 }}>
|
||||
Нет событий{logsRunId !== "" ? ` для run_id=${logsRunId}` : ""}.
|
||||
</p>
|
||||
) : (
|
||||
<div
|
||||
style={{
|
||||
maxHeight: 360,
|
||||
overflowY: "auto",
|
||||
fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace",
|
||||
fontSize: 12,
|
||||
border: "1px solid #eef0f3",
|
||||
borderRadius: 6,
|
||||
background: "#0f172a",
|
||||
color: "#e2e8f0",
|
||||
padding: 10,
|
||||
}}
|
||||
>
|
||||
{(logs.data ?? []).map((l) => {
|
||||
const color =
|
||||
l.level === "error"
|
||||
? "#fca5a5"
|
||||
: l.level === "warn"
|
||||
? "#fcd34d"
|
||||
: "#a3e635";
|
||||
return (
|
||||
<div key={l.log_id} style={{ marginBottom: 2 }}>
|
||||
<span style={{ color: "#94a3b8" }}>
|
||||
{l.ts?.slice(11, 19)}
|
||||
</span>{" "}
|
||||
<span style={{ color }}>{l.level.toUpperCase()}</span>{" "}
|
||||
<span style={{ color: "#7dd3fc" }}>
|
||||
[run-{l.run_id}
|
||||
{l.stage ? ` · ${l.stage}` : ""}
|
||||
{l.cad_number ? ` · ${l.cad_number}` : ""}]
|
||||
</span>{" "}
|
||||
<span>{l.message}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section style={{ ...cardStyle, marginTop: 16 }}>
|
||||
<h2 style={{ marginTop: 0, fontSize: 16 }}>Последние прогоны</h2>
|
||||
{!token ? (
|
||||
<p style={{ color: "#5b6066" }}>
|
||||
Введите Admin Token чтобы увидеть журнал.
|
||||
</p>
|
||||
) : runs.isLoading ? (
|
||||
<p>Загрузка…</p>
|
||||
) : runs.isError ? (
|
||||
<p style={{ color: "#b3261e" }}>Ошибка: {String(runs.error)}</p>
|
||||
) : (
|
||||
<table
|
||||
style={{ width: "100%", borderCollapse: "collapse", fontSize: 13 }}
|
||||
>
|
||||
<thead>
|
||||
<tr style={{ background: "#f6f7f9" }}>
|
||||
{[
|
||||
"#",
|
||||
"Старт",
|
||||
"HB",
|
||||
"Статус",
|
||||
"Прогресс",
|
||||
"Pending",
|
||||
"OK",
|
||||
"Fail",
|
||||
"Зданий",
|
||||
"Req",
|
||||
"WAF",
|
||||
"Trigger",
|
||||
"Ошибка",
|
||||
].map((h) => (
|
||||
<th key={h} style={th}>
|
||||
{h}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(runs.data ?? []).map((r) => (
|
||||
<tr
|
||||
key={r.run_id}
|
||||
style={{ borderBottom: "1px solid #eef0f3" }}
|
||||
>
|
||||
<td style={td}>{r.run_id}</td>
|
||||
<td style={td}>
|
||||
{r.started_at?.slice(0, 19).replace("T", " ")}
|
||||
</td>
|
||||
<td style={td}>
|
||||
{r.heartbeat_at ? r.heartbeat_at.slice(11, 19) : "—"}
|
||||
</td>
|
||||
<td
|
||||
style={{
|
||||
...td,
|
||||
color:
|
||||
r.status === "done"
|
||||
? "#0a7a3a"
|
||||
: r.status === "failed" || r.status === "zombie"
|
||||
? "#b3261e"
|
||||
: "#9a6700",
|
||||
fontWeight: 600,
|
||||
}}
|
||||
>
|
||||
{r.status}
|
||||
</td>
|
||||
<td style={{ ...td, minWidth: 110 }}>
|
||||
{r.pending_count && r.pending_count > 0
|
||||
? (() => {
|
||||
const pct = Math.min(
|
||||
100,
|
||||
Math.round(
|
||||
((r.quarters_ok + r.quarters_failed) /
|
||||
r.pending_count) *
|
||||
100,
|
||||
),
|
||||
);
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
fontFamily: "ui-monospace, monospace",
|
||||
fontSize: 12,
|
||||
display: "flex",
|
||||
alignItems: "center",
|
||||
gap: 6,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 70,
|
||||
height: 6,
|
||||
background: "#eef0f3",
|
||||
borderRadius: 3,
|
||||
overflow: "hidden",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: `${pct}%`,
|
||||
height: "100%",
|
||||
background:
|
||||
pct === 100
|
||||
? "#0a7a3a"
|
||||
: pct > 0
|
||||
? "#1d4ed8"
|
||||
: "#9a6700",
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span>{pct}%</span>
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
: "—"}
|
||||
</td>
|
||||
<td style={td}>{r.pending_count ?? "—"}</td>
|
||||
<td style={td}>{r.quarters_ok}</td>
|
||||
<td
|
||||
style={{
|
||||
...td,
|
||||
color: r.quarters_failed > 0 ? "#b3261e" : undefined,
|
||||
}}
|
||||
>
|
||||
{r.quarters_failed}
|
||||
</td>
|
||||
<td style={td}>{r.buildings_ok}</td>
|
||||
<td style={td}>{r.requests_count}</td>
|
||||
<td
|
||||
style={{
|
||||
...td,
|
||||
color: r.waf_429_count > 0 ? "#b3261e" : undefined,
|
||||
}}
|
||||
>
|
||||
{r.waf_429_count}
|
||||
</td>
|
||||
<td style={td}>{r.triggered_by}</td>
|
||||
<td
|
||||
style={{
|
||||
...td,
|
||||
color: "#b3261e",
|
||||
maxWidth: 240,
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap",
|
||||
}}
|
||||
title={r.error ?? ""}
|
||||
>
|
||||
{r.error ?? ""}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function CoverageCard({
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
color?: string;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
background: "#fff",
|
||||
border: "1px solid #e6e8ec",
|
||||
borderRadius: 8,
|
||||
padding: "10px 12px",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 11,
|
||||
color: "#5b6066",
|
||||
textTransform: "uppercase",
|
||||
letterSpacing: 0.4,
|
||||
marginBottom: 4,
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 22,
|
||||
fontWeight: 700,
|
||||
color: color ?? "#0f172a",
|
||||
lineHeight: 1.1,
|
||||
}}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const cardStyle = {
|
||||
background: "#fff",
|
||||
border: "1px solid #e6e8ec",
|
||||
borderRadius: 12,
|
||||
padding: 20,
|
||||
};
|
||||
const labelStyle = {
|
||||
display: "block",
|
||||
fontSize: 12,
|
||||
color: "#5b6066",
|
||||
marginBottom: 4,
|
||||
textTransform: "uppercase" as const,
|
||||
letterSpacing: 0.4,
|
||||
};
|
||||
const inputStyle = {
|
||||
padding: "8px 10px",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
width: "100%",
|
||||
boxSizing: "border-box" as const,
|
||||
};
|
||||
const primaryBtn = {
|
||||
padding: "10px 18px",
|
||||
background: "#1d4ed8",
|
||||
color: "#fff",
|
||||
border: "none",
|
||||
borderRadius: 6,
|
||||
fontSize: 14,
|
||||
fontWeight: 600,
|
||||
cursor: "pointer",
|
||||
};
|
||||
const secondaryBtn = {
|
||||
padding: "8px 14px",
|
||||
background: "#f3f4f6",
|
||||
color: "#1f2937",
|
||||
border: "1px solid #d1d5db",
|
||||
borderRadius: 6,
|
||||
fontSize: 13,
|
||||
cursor: "pointer",
|
||||
};
|
||||
const th = {
|
||||
padding: "8px 10px",
|
||||
textAlign: "left" as const,
|
||||
fontWeight: 600,
|
||||
borderBottom: "1px solid #e6e8ec",
|
||||
};
|
||||
const td = { padding: "8px 10px" };
|
||||
Loading…
Add table
Reference in a new issue