fix(tradein/admin): убрать показатели, которые не могут быть ненулевыми, и брать список источников из данных (#2674)
All checks were successful
CI / changes (pull_request) Successful in 7s
CI Trade-In / changes (pull_request) Successful in 8s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m1s
CI Trade-In / backend-tests (pull_request) Successful in 2m57s

Четыре находки одного класса: админка показывает числа, которые никогда не
бывают ненулевыми, и подаёт это как результат. Ноль читается оператором как
«всё чисто», а не как «мы это не считаем» — такой показатель хуже отсутствующего.

1. «Помечено выбросов» (v_data_quality.outliers_flagged) — УБРАН вместе с
   колонкой listings.is_outlier. Механизм не «не доделан»: «выброс» у эстиматора
   вычисляется Tukey-фильтром по КОНКРЕТНОЙ подборке аналогов и живёт один
   запрос — один и тот же лот выброс для одной оценки и нормальный аналог для
   соседней. Persist-флаг на объявлении такое отношение выразить не может,
   реализовать пометку нечем.

2. http_requests / http_errors / returning_count / disappeared_count — УБРАНЫ.
   HTTP-запросы не считает ни один фетчер (заполнить нечем без сквозной
   инструментации). Ошибки и «пропало/вернулось» уже считает тот, кто их знает,
   и кладёт в counters jsonb: errors_count у pipeline, deactivated/revived у
   deactivate_stale_*. Отдельные колонки были бы вторым определением того же.

3. run_type — УБРАН из API, из таблицы админки и из схемы. Ни одно место кода
   его не задавало; DEFAULT из 051 подписывал 'city_sweep' даже proxy_healthcheck.
   Колонка «Тип» в UI заменена на «Источник» — там осмысленное значение.

4. Фильтр источников — теперь из данных (GET /scrape/runs/sources, SELECT
   DISTINCT source). Захардкоженная тройка не просто была неполной: сравнение
   точное, а строк с source='avito'/'cian'/'yandex' в таблице нет вообще, то
   есть каждый пункт фильтра давал пустую выдачу, и пустой выбор («Все») тоже —
   он молча подставлял source вкладки. Новый источник появляется в списке сам.

Числа с прода (tradein-postgres, 2026-08-06): is_outlier=true у 0 из 93 408
listings (NULL у 0 — только DEFAULT); четыре счётчика = 0 во всех 3244 прогонах
с миграции 015; run_type — одно значение на 3244 строки; 53 реальных источника,
2466 прогонов (76%) вне трёх площадок, включая весь Домклик.

Миграция 214 идемпотентна; v_data_quality пересоздан тем же DDL минус
outliers_flagged (порядок DROP VIEW → DROP COLUMN → CREATE как в 095).
This commit is contained in:
bot-backend 2026-08-06 03:34:18 +05:00
parent 673c02e5d6
commit 4d0795ae7a
7 changed files with 440 additions and 46 deletions

View file

@ -2189,11 +2189,17 @@ async def scrape_house_imv_backfill(
class UnifiedScrapeRunRow(BaseModel):
"""Строка scrape_runs для unified-таблицы (все source'ы в одной выдаче)."""
"""Строка scrape_runs для unified-таблицы (все source'ы в одной выдаче).
#2674: поля run_type больше нет. Вид прогона в БД всегда был дефолтом
'city_sweep' (3244 из 3244 строк, ни одно место кода его не задавало), и
таблица подписывала им прогоны, которые никаким sweep не были
proxy_healthcheck, deactivate_stale_*, sber_index_pull. Что именно бежало,
называет `source`.
"""
run_id: int
source: str
run_type: str | None = None
status: str
params: dict | None = None
counters: dict | None = None
@ -2210,6 +2216,12 @@ class UnifiedScrapeRunsResponse(BaseModel):
rows: list[UnifiedScrapeRunRow]
class ScrapeRunSourcesResponse(BaseModel):
"""Список source'ов для фильтра истории прогонов — из данных, не из литерала."""
sources: list[str]
class BrowserHealth(BaseModel):
reachable: bool
browsers: dict[str, bool] = Field(default_factory=dict)
@ -2294,7 +2306,6 @@ def list_scrape_runs_unified(
UnifiedScrapeRunRow(
run_id=r["run_id"],
source=r["source"],
run_type=r.get("run_type"),
status=r["status"],
params=r.get("params"),
counters=r.get("counters"),
@ -2310,6 +2321,21 @@ def list_scrape_runs_unified(
)
@router.get("/scrape/runs/sources", response_model=ScrapeRunSourcesResponse)
def list_scrape_run_sources(
db: Annotated[Session, Depends(get_db)],
) -> ScrapeRunSourcesResponse:
"""Источники для фильтра истории прогонов — ровно те, что есть в scrape_runs.
#2674: фильтр в UI был захардкожен тремя значениями (avito/cian/yandex), а в
таблице 53 разных source и НИ ОДНОЙ строки с таким точным значением каждый
пункт фильтра давал пустую выдачу, и 76% прогонов (вся площадка Домклик в том
числе) были недоступны для вопроса «что там происходит». Список берётся из
данных: новый source появляется в фильтре сам, без правки кода.
"""
return ScrapeRunSourcesResponse(sources=runs_mod.distinct_sources(db))
async def _probe_browser_health() -> BrowserHealth:
"""GET tradein-browser /health (timeout 5с). reachable=False при ошибке."""
url = f"{settings.browser_http_endpoint.rstrip('/')}/health"

View file

@ -201,7 +201,9 @@ def _alert_on_run_id(
def create_run(db: Session, *, source: str, params: dict[str, Any]) -> int:
"""INSERT scrape_runs(source, status='running', params, started_at=NOW()).
run_type DEFAULT 'city_sweep' (из 051 миграции).
Вид прогона несёт сам `source` (avito_city_sweep / domclick_detail_backfill / );
отдельной колонки run_type больше нет она 3244 прогона подряд молчала
дефолтом 'city_sweep' и подписывала им, например, proxy_healthcheck (#2674).
Returns run_id (bigint).
"""
row = db.execute(
@ -445,7 +447,7 @@ def list_all(
db.execute(
text(
f"""
SELECT id AS run_id, source, run_type, status, params, counters,
SELECT id AS run_id, source, status, params, counters,
total_seen, new_count, started_at, finished_at,
heartbeat_at, error AS error_text
FROM scrape_runs
@ -460,3 +462,22 @@ def list_all(
.all()
)
return total, [dict(r) for r in rows]
def distinct_sources(db: Session) -> list[str]:
"""Все значения source, которые РЕАЛЬНО есть в scrape_runs (по алфавиту).
#2674: фильтр источников в админке был захардкожен тремя площадками
(avito/cian/yandex), а в таблице 53 разных source и ни одной строки с таким
точным значением все три пункта фильтра давали пустую выдачу, а 76%
прогонов (включая всю площадку Домклик) отфильтровать было нечем.
Список обязан приходить из данных: новый source появляется в фильтре сам,
без правки кода.
Игнорирует фильтры /scrape/runs иначе выбор источника вырезал бы из
выпадающего списка все остальные.
"""
rows = db.execute(
text("SELECT DISTINCT source FROM scrape_runs WHERE source IS NOT NULL ORDER BY source")
).fetchall()
return [str(r.source) for r in rows]

View file

@ -0,0 +1,109 @@
-- 214_drop_dead_run_metrics.sql
-- Purpose (#2674): удалить показатели, которые не могут быть ненулевыми.
--
-- Один класс находок: колонка есть, админка её показывает, писателя нет ни одного —
-- и ноль читается как «всё чисто», а не как «мы это не считаем». Такой показатель
-- хуже отсутствующего: он создаёт ложную уверенность. Числа с прода 2026-08-06.
--
-- 1. listings.is_outlier + v_data_quality.outliers_flagged
-- 93 408 строк listings, is_outlier = false у ВСЕХ, NULL у нуля — то есть
-- только DEFAULT из 002, ни одного UPDATE за всю историю. Писателя нет и не
-- задумывалось: «выброс» у эстиматора вычисляется Tukey-фильтром по КОНКРЕТНОЙ
-- подборке аналогов (estimator._filter_outliers) и живёт ровно один запрос —
-- один и тот же лот выброс для одной оценки и нормальный аналог для соседней.
-- Persist-флаг на объявлении такое отношение выразить не может в принципе,
-- поэтому реализовать «пометку» нечем: это остаток отменённой затеи
-- («помечено модели после aggregation», 002_core_tables.sql), а не недоделка.
--
-- 2. scrape_runs.run_type
-- 3244 прогона, ровно одно значение 'city_sweep' — DEFAULT из 051; ни одно
-- место кода run_type не задаёт. Колонка подписывала «city_sweep» прогоны,
-- которые никаким sweep не были: proxy_healthcheck (1630 шт.),
-- deactivate_stale_*, sber_index_pull, rosreestr_dkp_import. Что именно бежало,
-- честно называет source — второй, вырожденной оси не нужно.
--
-- 3. scrape_runs.http_requests / http_errors / returning_count / disappeared_count
-- Ноль во всех 3244 прогонах с миграции 015, писателя нет вообще.
-- HTTP-запросы не считает никто ни в одном фетчере — заполнить нечем без
-- сквозной инструментации всех путей. Ошибки и «пропало/вернулось» УЖЕ
-- считает тот, кто их знает, и кладёт в counters jsonb: errors_count у
-- pipeline, deactivated/revived у deactivate_stale_*. Отдельные колонки были
-- бы вторым, иначе определённым счётчиком того же слова.
--
-- ⚠️ View-зависимость: v_data_quality (последний DDL — 095_dead_schema.sql) читает
-- listings.is_outlier и содержит CTE `SELECT * FROM listings`, что фиксирует
-- column-level зависимость на все колонки. Порядок тот же, что в 095:
-- DROP VIEW → DROP COLUMN → CREATE VIEW (уже без outliers_flagged).
--
-- Dependencies: 002_core_tables.sql, 015_scrape_runs.sql, 051_scrape_runs_extend.sql,
-- 095_dead_schema.sql (последний DDL v_data_quality).
-- Идемпотентно: DROP VIEW IF EXISTS / DROP COLUMN IF EXISTS / CREATE OR REPLACE VIEW.
BEGIN;
-- ── 1. is_outlier: снять зависимый view, дропнуть колонку, пересоздать view ───
DROP VIEW IF EXISTS v_data_quality;
ALTER TABLE IF EXISTS listings DROP COLUMN IF EXISTS is_outlier;
-- DDL идентичен 095, минус строка outliers_flagged (см. п.1 шапки).
CREATE OR REPLACE VIEW v_data_quality AS
WITH active_listings AS (
SELECT * FROM listings WHERE is_active = true
)
SELECT
(SELECT count(*) FROM houses) AS houses_total,
(SELECT count(*) FROM houses h
WHERE EXISTS (SELECT 1 FROM house_sources hs WHERE hs.house_id = h.id)) AS houses_with_source,
(SELECT count(*) FROM houses h
WHERE EXISTS (SELECT 1 FROM house_sources hs
WHERE hs.house_id = h.id AND hs.ext_source = 'avito')) AS houses_with_avito,
(SELECT count(*) FROM houses h
WHERE EXISTS (SELECT 1 FROM house_sources hs
WHERE hs.house_id = h.id AND hs.ext_source LIKE 'cian%')) AS houses_with_cian,
(SELECT count(*) FROM houses h
WHERE EXISTS (SELECT 1 FROM house_sources hs
WHERE hs.house_id = h.id AND hs.ext_source = 'yandex')) AS houses_with_yandex,
(SELECT count(*) FROM (
SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 2
) sub) AS houses_2plus_sources,
(SELECT count(*) FROM (
SELECT house_id FROM house_sources GROUP BY house_id HAVING count(*) >= 3
) sub) AS houses_3plus_sources,
(SELECT count(*) FROM active_listings) AS listings_active,
(SELECT count(*) FROM (
SELECT listing_id FROM listing_sources
WHERE listing_id IN (SELECT id FROM active_listings)
GROUP BY listing_id HAVING count(*) >= 2
) sub) AS listings_dedup_2sources,
(SELECT count(*) FROM active_listings WHERE lat IS NOT NULL) * 100.0
/ NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_geocoded,
(SELECT count(*) FROM active_listings WHERE cadastral_number IS NOT NULL) * 100.0
/ NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_cadastr,
(SELECT count(*) FROM active_listings WHERE description IS NOT NULL) * 100.0
/ NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_description,
(SELECT count(*) FROM active_listings l
JOIN houses h ON h.id = l.house_id_fk
WHERE h.year_built IS NOT NULL) * 100.0
/ NULLIF((SELECT count(*) FROM active_listings), 0) AS pct_year_built,
NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'avito') AS avito_last_scrape_ago,
NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'cian') AS cian_last_scrape_ago,
NOW() - (SELECT max(scraped_at) FROM listings WHERE source = 'yandex') AS yandex_last_scrape_ago,
(SELECT count(*) FROM v_price_divergence) AS price_disagreements_count;
COMMENT ON VIEW v_data_quality IS
'KPI snapshot. Refreshed on-demand by /api/v1/admin/data-quality endpoint (Master Plan sec 8.1). '
'#2674: outliers_flagged убран — is_outlier не писал никто, «выброс» определён только '
'внутри одной подборки аналогов (estimator._filter_outliers), не на объявлении.';
-- ── 2-3. scrape_runs: вырожденный run_type + четыре счётчика без писателя ─────
ALTER TABLE IF EXISTS scrape_runs
DROP COLUMN IF EXISTS run_type,
DROP COLUMN IF EXISTS http_requests,
DROP COLUMN IF EXISTS http_errors,
DROP COLUMN IF EXISTS returning_count,
DROP COLUMN IF EXISTS disappeared_count;
COMMIT;

View file

@ -0,0 +1,221 @@
"""#2674 — показатели админки, которые не могут быть ненулевыми.
Один класс: колонка есть, поверхность её показывает, писателя нет ни одного и
ноль читается оператором как «всё чисто», а не как «мы это не считаем». Такой
показатель хуже отсутствующего, поэтому «честно убрать» здесь такой же
правильный исход, как «реализовать».
Числа с прода (tradein-postgres, 2026-08-06, до фикса):
1. listings.is_outlier 93 408 строк, true у 0, NULL у 0: только DEFAULT из
миграции 002, ни одного UPDATE. v_data_quality.outliers_flagged рапортовал
этот ноль как «выбросов не помечено».
2. scrape_runs.http_requests / http_errors / returning_count /
disappeared_count ноль во всех 3244 прогонах с миграции 015 (74 дня у
самого старого), писателя нет вообще.
3. scrape_runs.run_type 3244 строки, ровно одно значение 'city_sweep'
(DEFAULT из 051); таблица админки подписывала им proxy_healthcheck (1630
прогонов), deactivate_stale_*, sber_index_pull.
4. Фильтр источников знал три значения ('avito'/'cian'/'yandex'), а
`source = :source` точное сравнение: строк с ТАКИМ source в таблице 0.
То есть каждый пункт фильтра давал пустую выдачу, а пустой выбор («Все»)
молча подставлял source вкладки и тоже ноль. 53 реальных источника,
2466 прогонов (76%) вне трёх площадок, включая весь Домклик.
БД и сеть замоканы реального Postgres не нужно.
"""
from __future__ import annotations
import inspect
import os
import re
from pathlib import Path
from typing import Any
from unittest.mock import MagicMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
from scraper_kit.orchestration import runs as kit_runs
from app.services import scrape_runs as runs_mod
_BACKEND_ROOT = Path(__file__).resolve().parents[1]
_SQL_DIR = _BACKEND_ROOT / "data" / "sql"
_RUNS_TABLE_TSX = (
_BACKEND_ROOT.parent / "frontend" / "src" / "components" / "scrapers" / "RunsTable.tsx"
)
# Колонки scrape_runs без единого писателя (см. шапку). Плюс listings.is_outlier.
_DEAD_RUN_COLUMNS = (
"run_type",
"http_requests",
"http_errors",
"returning_count",
"disappeared_count",
)
# ── Фейковая БД: отдаёт ровно те source'ы, что ей задали ──────────────────────
class _FakeRow:
def __init__(self, source: str) -> None:
self.source = source
class _FakeResult:
def __init__(self, rows: list[_FakeRow]) -> None:
self._rows = rows
def fetchall(self) -> list[_FakeRow]:
return self._rows
class _FakeDB:
"""Минимальный Session-стаб: любой execute отдаёт заданный список source'ов."""
def __init__(self, sources: list[str]) -> None:
self.sources = sources
self.statements: list[str] = []
def execute(self, stmt: Any, params: Any = None) -> _FakeResult:
self.statements.append(str(stmt))
return _FakeResult([_FakeRow(s) for s in self.sources])
@pytest.fixture
def client_factory():
from app.api.v1 import admin as admin_module
from app.core.db import get_db
def _make(db: Any) -> TestClient:
app = FastAPI()
app.include_router(admin_module.router, prefix="/api/v1/admin")
app.dependency_overrides[get_db] = lambda: db
return TestClient(app)
return _make
# ══ 4. Фильтр источников берётся из данных ════════════════════════════════════
def test_sources_endpoint_returns_whatever_the_table_holds(client_factory) -> None:
"""Новый источник появляется в фильтре БЕЗ правки кода.
'domclick_city_sweep' площадка, которой не было в захардкоженной тройке
(46 прогонов на проде нельзя было отфильтровать). 'newmarket_2031_sweep'
заведомо несуществующий источник: если он доезжает до ответа, список
действительно приходит из данных, а не из литерала в коде.
"""
db = _FakeDB(["avito_city_sweep", "domclick_city_sweep", "newmarket_2031_sweep"])
r = client_factory(db).get("/api/v1/admin/scrape/runs/sources")
assert r.status_code == 200
assert r.json()["sources"] == [
"avito_city_sweep",
"domclick_city_sweep",
"newmarket_2031_sweep",
]
def test_sources_query_ignores_run_filters() -> None:
"""SELECT DISTINCT без WHERE по source/status — иначе выбор одного источника
вырезал бы из выпадающего списка все остальные."""
sql = inspect.getsource(runs_mod.distinct_sources)
assert "DISTINCT source" in sql
assert "WHERE source = " not in sql
assert ":status" not in sql
def test_frontend_source_filter_has_no_hardcoded_list() -> None:
"""RunsTable.tsx не содержит литерального списка площадок.
Red на origin/main: там был `["", "avito", "cian", "yandex"] as const`
три значения, которых в колонке source нет ни в одной из 3244 строк.
"""
tsx = _RUNS_TABLE_TSX.read_text(encoding="utf-8")
hardcoded = re.search(r'"avito"\s*,\s*"cian"\s*,\s*"yandex"', tsx)
assert hardcoded is None, (
"список источников снова захардкожен в RunsTable.tsx — он обязан приходить "
"из /api/v1/admin/scrape/runs/sources, иначе следующая площадка опять "
"останется невидимой для оператора"
)
assert "/scrape/runs/sources" in tsx
# ══ 3. run_type: вырожденная колонка ушла из API и из выдачи ══════════════════
def test_unified_run_row_has_no_run_type(client_factory) -> None:
"""Ответ /scrape/runs не несёт run_type — подписывать 'city_sweep' прогон
proxy_healthcheck честнее вообще нечем, кроме source."""
from unittest.mock import patch
row = {
"run_id": 5,
"source": "proxy_healthcheck",
"status": "done",
"params": None,
"counters": {"ok": 3},
"total_seen": None,
"new_count": None,
"started_at": None,
"finished_at": None,
"heartbeat_at": None,
"error_text": None,
}
with patch("app.services.scrape_runs.list_all", return_value=(1, [row])):
r = client_factory(MagicMock()).get("/api/v1/admin/scrape/runs")
assert r.status_code == 200
body_row = r.json()["rows"][0]
assert "run_type" not in body_row
assert body_row["source"] == "proxy_healthcheck"
@pytest.mark.parametrize("list_all", [runs_mod.list_all, kit_runs.list_all])
def test_list_all_selects_no_dropped_columns(list_all) -> None:
"""Обе копии list_all (app.services и scraper_kit) перестали читать колонки,
которых после миграции 214 в таблице нет иначе админка отдаёт 500."""
sql = inspect.getsource(list_all)
still_there = [c for c in _DEAD_RUN_COLUMNS if re.search(rf"\b{c}\b", sql)]
assert not still_there, f"list_all всё ещё выбирает дропнутые колонки: {still_there}"
# ══ 1-3. Схема: колонок больше нет, и v_data_quality не рапортует выбросы ═════
def _migration_214() -> str:
matches = sorted(_SQL_DIR.glob("214_*.sql"))
assert matches, "не найдена миграция 214_* (удаление мёртвых показателей #2674)"
return matches[0].read_text(encoding="utf-8")
def test_migration_drops_every_dead_column() -> None:
"""Все пять колонок scrape_runs + listings.is_outlier дропаются идемпотентно."""
sql = _migration_214()
missing = [c for c in _DEAD_RUN_COLUMNS if f"DROP COLUMN IF EXISTS {c}" not in sql]
assert not missing, f"миграция не дропает: {missing}"
assert "DROP COLUMN IF EXISTS is_outlier" in sql
def test_latest_v_data_quality_no_longer_reports_outliers() -> None:
"""Действующее определение v_data_quality (последняя миграция, которая его
создаёт) не упоминает is_outlier.
Red на origin/main: там последним был 095_dead_schema.sql со строкой
`(SELECT count(*) FROM listings WHERE is_outlier = true) AS outliers_flagged`
показатель, который не мог быть ненулевым, потому что колонку не писал никто.
"""
marker = "CREATE OR REPLACE VIEW v_data_quality"
creators = sorted(p for p in _SQL_DIR.glob("*.sql") if marker in p.read_text(encoding="utf-8"))
assert creators, "не найдено ни одной миграции, создающей v_data_quality"
latest = creators[-1].read_text(encoding="utf-8")
body = latest.split("CREATE OR REPLACE VIEW v_data_quality")[-1].split(";")[0]
assert "outliers_flagged" not in body
assert "is_outlier" not in body

View file

@ -42,12 +42,15 @@ def client() -> TestClient:
def test_unified_runs_returns_rows_and_total(client: TestClient) -> None:
"""list_all → total + rows; ISO-форматирование дат + error_text/run_type."""
"""list_all → total + rows; ISO-форматирование дат + error_text.
run_type из выдачи убран (#2674): вырожденная колонка (одно значение во всех
3244 прогонах), вид прогона называет source.
"""
fake_rows = [
{
"run_id": 5,
"source": "avito_city_sweep",
"run_type": "city_sweep",
"status": "done",
"params": {"pages_per_anchor": 3},
"counters": {"lots_fetched": 120},
@ -68,7 +71,7 @@ def test_unified_runs_returns_rows_and_total(client: TestClient) -> None:
row = body["rows"][0]
assert row["run_id"] == 5
assert row["source"] == "avito_city_sweep"
assert row["run_type"] == "city_sweep"
assert "run_type" not in row
assert row["total_seen"] == 120
assert row["new_count"] == 30
assert row["error_text"] is None

View file

@ -15,7 +15,6 @@ import {
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;
@ -32,6 +31,10 @@ interface RunsListResp {
rows: ScrapeRunFull[];
}
interface RunSourcesResp {
sources: string[];
}
// ── Hook ───────────────────────────────────────────────────────────────────
// "skipped" (#2658) — пропущенное расписание (нет кук / уже бежит / нет handler'а);
@ -48,35 +51,32 @@ const RUN_STATUS_ALL = [
] as const;
type RunStatusFilter = (typeof RUN_STATUS_ALL)[number];
// "" means "all sources"; otherwise a specific source prefix (avito / cian / yandex)
const RUN_SOURCE_FILTERS = ["", "avito", "cian", "yandex"] as const;
type RunSourceFilter = (typeof RUN_SOURCE_FILTERS)[number];
// #2674: список источников приходит из данных, а не из литерала. Раньше здесь
// стояли три площадки (avito/cian/yandex), а в scrape_runs 53 разных source и НИ
// ОДНОЙ строки с таким точным значением — каждый пункт фильтра давал пустую
// выдачу, а 76% прогонов (вся площадка Домклик в том числе) отфильтровать было
// нечем. Новый источник теперь появляется в списке сам.
function useScrapeRunSources() {
return useQuery<RunSourcesResp>({
queryKey: ["scrape-run-sources"],
// Отдельный ключ (не зависит от фильтров) — иначе список схлопывался бы при
// каждой смене фильтра, пока летит запрос.
queryFn: () =>
apiFetch<RunSourcesResp>("/api/v1/admin/scrape/runs/sources"),
staleTime: 60_000,
retry: 1,
});
}
const SOURCE_FILTER_LABELS: Record<RunSourceFilter, string> = {
"": "Все",
avito: "Avito",
cian: "Cian",
yandex: "Yandex",
};
function useScraperRuns(
source: ScraperSource,
status: RunStatusFilter,
sourceFilter: RunSourceFilter,
limit = 20,
) {
function useScraperRuns(status: RunStatusFilter, sourceFilter: string, limit = 20) {
return useQuery<RunsListResp>({
queryKey: ["scrape-runs", source, status, sourceFilter, limit],
queryKey: ["scrape-runs", status, sourceFilter, limit],
queryFn: () => {
const qs = new URLSearchParams({ limit: String(limit) });
// When a specific sourceFilter is chosen, ignore the tab-level source
// and pass it verbatim as the ?source= param
if (sourceFilter) {
qs.set("source", sourceFilter);
} else {
// fallback: filter by the current tab provider
qs.set("source", source);
}
// Пусто = «Все», и это буквально все источники. Раньше пустой выбор молча
// подставлял source вкладки ("avito"), которого в данных не существует, —
// подпись «Все» показывала ноль прогонов (#2674).
if (sourceFilter) qs.set("source", sourceFilter);
if (status) qs.set("status", status);
return apiFetch<RunsListResp>(
`/api/v1/admin/scrape/runs?${qs.toString()}`,
@ -186,16 +186,19 @@ interface RunsTableProps {
export function RunsTable({ source }: RunsTableProps) {
const [statusFilter, setStatusFilter] = useState<RunStatusFilter>("");
const [sourceFilter, setSourceFilter] = useState<RunSourceFilter>("");
const [sourceFilter, setSourceFilter] = useState<string>("");
const qc = useQueryClient();
const runsQ = useScraperRuns(source, statusFilter, sourceFilter);
const runsQ = useScraperRuns(statusFilter, sourceFilter);
const sourcesQ = useScrapeRunSources();
// mark_cancelled(run_id) source-агностичен — отменяется тот прогон, что выбран
// в таблице, независимо от вкладки, с которой нажали.
const cancelMut = useCancelCitySweep(source);
function handleCancel(runId: number) {
cancelMut.mutate(runId, {
onSuccess: () => {
void qc.invalidateQueries({
queryKey: ["scrape-runs", source, statusFilter, sourceFilter],
queryKey: ["scrape-runs", statusFilter, sourceFilter],
});
},
});
@ -205,7 +208,7 @@ export function RunsTable({ source }: RunsTableProps) {
<section className="scraper-section">
<h2>История прогонов</h2>
<p className="scraper-hint">
Последние 20 прогонов. Автообновление каждые 8 сек.
Последние 20 прогонов по всем источникам. Автообновление каждые 8 сек.
</p>
{/* Source filter */}
@ -219,7 +222,7 @@ export function RunsTable({ source }: RunsTableProps) {
<select
id="runs-source-filter"
value={sourceFilter}
onChange={(e) => setSourceFilter(e.target.value as RunSourceFilter)}
onChange={(e) => setSourceFilter(e.target.value)}
style={{
fontSize: "0.8rem",
padding: "3px 8px",
@ -231,12 +234,18 @@ export function RunsTable({ source }: RunsTableProps) {
}}
aria-label="Фильтр по источнику"
>
{RUN_SOURCE_FILTERS.map((sf) => (
<option key={sf || "_all"} value={sf}>
{SOURCE_FILTER_LABELS[sf]}
<option value="">Все</option>
{(sourcesQ.data?.sources ?? []).map((sf) => (
<option key={sf} value={sf}>
{sf}
</option>
))}
</select>
{sourcesQ.isError && (
<span style={{ fontSize: "0.75rem", color: "var(--danger, #b3261e)" }}>
список источников не загрузился фильтр показывает только «Все»
</span>
)}
</div>
{/* Status filter */}
@ -301,7 +310,10 @@ export function RunsTable({ source }: RunsTableProps) {
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">Тип</th>
{/* #2674: было «Тип» = run_type, а он у всех 3244 прогонов один
('city_sweep' по DEFAULT) и подписывал так даже
proxy_healthcheck. Что бежало говорит source. */}
<th scope="col">Источник</th>
<th scope="col">Статус</th>
<th scope="col">Старт</th>
<th scope="col">Финиш</th>
@ -323,7 +335,7 @@ export function RunsTable({ source }: RunsTableProps) {
color: "var(--fg-secondary, #5b6066)",
}}
>
{r.run_type ?? "sweep"}
{r.source}
</span>
</td>
<td>

View file

@ -216,7 +216,9 @@ def _alert_on_run_id(
def create_run(db: Session, *, source: str, params: dict[str, Any]) -> int:
"""INSERT scrape_runs(source, status='running', params, started_at=NOW()).
run_type DEFAULT 'city_sweep' (из 051 миграции).
Вид прогона несёт сам `source` (avito_city_sweep / domclick_detail_backfill / );
отдельной колонки run_type больше нет она 3244 прогона подряд молчала
дефолтом 'city_sweep' и подписывала им, например, proxy_healthcheck (#2674).
Returns run_id (bigint).
"""
row = db.execute(
@ -549,7 +551,7 @@ def list_all(
db.execute(
text(
f"""
SELECT id AS run_id, source, run_type, status, params, counters,
SELECT id AS run_id, source, status, params, counters,
total_seen, new_count, started_at, finished_at,
heartbeat_at, error AS error_text
FROM scrape_runs