From 63e33e1861820a7cc1815e1d2d705b7c554b2c9e Mon Sep 17 00:00:00 2001 From: IK Date: Wed, 9 Sep 2026 20:02:52 +0700 Subject: [PATCH] =?UTF-8?q?fix:=20audit=20P0-P1=20=E2=80=94=20T03-T07,=20D?= =?UTF-8?q?01-D08,=20U01-U02=20(#1788956171115)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/api/app/activity.py | 2 +- apps/api/app/community_importer.py | 29 +++++++++---- apps/api/app/community_scheduler.py | 16 ++++--- apps/api/app/main.py | 45 +++++++++++-------- apps/api/app/readiness.py | 27 +++++++++++- apps/api/app/schemas.py | 19 ++++++++ apps/web/src/lib/api.ts | 7 +++ apps/web/src/pages/api/report-screenshot.ts | 2 +- apps/web/src/pages/api/report.ts | 11 +++-- apps/web/src/pages/index.astro | 34 +++++++++------ apps/web/src/pages/spots/[id].astro | 2 +- apps/web/src/styles/global.css | 5 ++- compose.production.yaml | 2 +- deploy/Caddyfile | 1 + deploy/monitor.sh | 2 +- deploy/test-production-bootstrap.sh | 2 +- rf4_research/community_cli.py | 48 +++++++++++++++------ 17 files changed, 182 insertions(+), 72 deletions(-) diff --git a/apps/api/app/activity.py b/apps/api/app/activity.py index 970af63..245f253 100644 --- a/apps/api/app/activity.py +++ b/apps/api/app/activity.py @@ -56,7 +56,7 @@ def activity_rows( activity = round(55 * min(1, weighted / 12) + 25 * min(1, len(players) / 6) + 20 * min(1, trophies / 3)) average_confidence = sum(r.source_confidence for r in items) / len(items) confidence = round(45 * min(1, len(items) / 10) + 35 * min(1, len(players) / 5) + 20 * average_confidence / 100) - latest = max(_aware(r.caught_at or r.reported_at) for r in items) + latest = max(_aware(r.reported_at) for r in items) baits = Counter(r.bait.name for r in items if r.bait) freshness_text = _freshness_text(now - latest) result.append(ActivityOut( diff --git a/apps/api/app/community_importer.py b/apps/api/app/community_importer.py index 119d26c..c128278 100644 --- a/apps/api/app/community_importer.py +++ b/apps/api/app/community_importer.py @@ -9,7 +9,7 @@ from sqlalchemy import select from sqlalchemy.orm import Session from .community_review import publish_observation -from .models import DataSource, ExternalEntityAlias, ExternalObservation, ModerationStatus +from .models import DataSource, ExternalEntityAlias, ExternalObservation, ModerationStatus, Waterbody SOURCE_DEFAULTS = { @@ -107,7 +107,6 @@ def _auto_publish(session: Session, observation: ExternalObservation) -> bool: observation.status not in {"staged", "mapped", "ready"} or not observation.source.enabled or observation.fish_external_id is None - or observation.waterbody_external_id is None or observation.x is None or observation.y is None or observation.weight_g is None @@ -118,15 +117,27 @@ def _auto_publish(session: Session, observation: ExternalObservation) -> bool: ExternalEntityAlias.entity_type == "fish", ExternalEntityAlias.external_id == observation.fish_external_id, )) - waterbody_alias = session.scalar(select(ExternalEntityAlias).where( - ExternalEntityAlias.source_system == observation.source_system, - ExternalEntityAlias.entity_type == "waterbody", - ExternalEntityAlias.external_id == observation.waterbody_external_id, - )) - if fish_alias is None or fish_alias.fish is None or waterbody_alias is None or waterbody_alias.waterbody is None: + if fish_alias is None or fish_alias.fish is None: + return False + # Waterbody: prefer external alias, fall back to exact name match + waterbody = None + if observation.waterbody_external_id is not None: + waterbody_alias = session.scalar(select(ExternalEntityAlias).where( + ExternalEntityAlias.source_system == observation.source_system, + ExternalEntityAlias.entity_type == "waterbody", + ExternalEntityAlias.external_id == observation.waterbody_external_id, + )) + if waterbody_alias and waterbody_alias.waterbody: + waterbody = waterbody_alias.waterbody + if waterbody is None: + # Fallback: exact name match + waterbody = session.scalar( + select(Waterbody).where(Waterbody.name_ru == observation.waterbody_name) + ) + if waterbody is None: return False observation.fish = fish_alias.fish - observation.waterbody = waterbody_alias.waterbody + observation.waterbody = waterbody observation.status = "ready" observation.review_note = "Automatically matched by previously reviewed source aliases" publish_observation(session, observation) diff --git a/apps/api/app/community_scheduler.py b/apps/api/app/community_scheduler.py index 76ff216..fb913c9 100644 --- a/apps/api/app/community_scheduler.py +++ b/apps/api/app/community_scheduler.py @@ -27,12 +27,18 @@ def retry_delay(statuses: list[str]) -> int: return min(settings.community_import_interval_seconds * (2 ** max(0, failures - 1)), MAX_BACKOFF_SECONDS) def configured_sources(): + with SessionLocal() as session: + enabled_keys = { + s.key for s in session.scalars(select(DataSource).where(DataSource.enabled.is_(True))) + } return { - "rf4db": SOURCES["rf4db"], - "rf4stat-fishing": SOURCES["rf4stat-fishing"], - "rf4stat-post": (SOURCES["rf4stat-posts"][0], SOURCES["rf4stat-posts"][1]), - "rf4map": (settings.rf4map_point_url, parse_rf4map_point), - "rf4posts-spot": (settings.rf4posts_spot_url, parse_rf4posts_spot), + k: v for k, v in { + "rf4db": SOURCES["rf4db"], + "rf4stat-fishing": SOURCES["rf4stat-fishing"], + "rf4stat-post": (SOURCES["rf4stat-posts"][0], SOURCES["rf4stat-posts"][1]), + "rf4map": (settings.rf4map_point_url, parse_rf4map_point), + "rf4posts-spot": (settings.rf4posts_spot_url, parse_rf4posts_spot), + }.items() if k in enabled_keys } def oldest_site_source(source_system: str, latest_by_source: dict[str, datetime]) -> str: diff --git a/apps/api/app/main.py b/apps/api/app/main.py index bf8c481..e01e2a4 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -27,7 +27,7 @@ from .logging_config import configure_logging from .models import Bait, BaitKind, CatchReport, CommunityImportRun, DataSource, ExternalObservation, Fish, ModerationEvent, ModerationStatus, OfficialRecordImport, SourceType, Spot, SubmissionAttempt, Waterbody from .readiness import readiness_report from .public_cache import public_cache -from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ModerationUpdate, OfficialRecordOut, PublicObservationOut, SourceStatusOut, SpotOut, WaterbodyOut +from .schemas import ActivityOut, AdminCatchReportOut, BaitOut, CatchOut, CatchReportAccepted, CatchReportCreate, CatchReportCreated, ExternalAliasSuggestionOut, ExternalObservationDecision, ExternalObservationMapping, ExternalObservationOut, ExternalObservationPublished, FishOut, ImportRunOut, ImportRunPublicOut, ModerationUpdate, OfficialRecordOut, PaginatedActivityOut, PublicObservationOut, SourceStatusOut, SpotOut, WaterbodyOut from .storage import ScreenshotError, client as storage_client, delete_screenshot, signed_screenshot_url, upload_screenshot @@ -87,6 +87,7 @@ def ready(db: Db) -> JSONResponse: is_ready, components = readiness_report( db, storage_client(), import_required=settings.official_import_required, import_interval_seconds=settings.import_interval_seconds, + community_import_interval_seconds=settings.community_import_interval_seconds, ) return JSONResponse( status_code=200 if is_ready else 503, @@ -120,14 +121,14 @@ def public_spot_pages(db: Db, limit: int = Query(500, ge=1, le=500), offset: int (f"/spots/{water}-{x}x{y}", f"/waterbodies/{water}/{fish}")] -@app.get("/api/v1/activity", response_model=list[ActivityOut]) +@app.get("/api/v1/activity", response_model=PaginatedActivityOut) def activity( db: Db, response: Response, hours: int = Query(24), waterbody: str | None = None, fish: str | None = None, method: str | None = None, sort: Literal["activity", "confidence", "freshness"] = "activity", limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0), -) -> list[ActivityOut]: +) -> PaginatedActivityOut: if hours not in {6, 12, 24, 72}: raise HTTPException(status_code=422, detail="hours must be one of: 6, 12, 24, 72") response.headers["Cache-Control"] = "no-store" @@ -138,14 +139,16 @@ def activity( response.headers["X-Cache"] = "HIT" return cached rows = activity_rows(db, hours=hours, waterbody=waterbody, fish=fish, method=method) + total = len(rows) keys = { "activity": lambda r: (r.activity_score, r.confidence_score, r.last_confirmed_at, str(r.spot_id)), "confidence": lambda r: (r.confidence_score, r.activity_score, r.last_confirmed_at, str(r.spot_id)), "freshness": lambda r: (r.last_confirmed_at, r.activity_score, r.confidence_score, str(r.spot_id)), } rows.sort(key=keys[sort], reverse=True) + page = rows[offset:offset + limit] response.headers["X-Cache"] = "MISS" - return public_cache.set(cache_key, rows[offset:offset + limit], generation=generation) + return public_cache.set(cache_key, PaginatedActivityOut(items=page, total=total, limit=limit, offset=offset), generation=generation) def _spot_or_404(db: Session, spot_id: UUID) -> Spot: @@ -218,19 +221,18 @@ def _report_source(report: CatchReport) -> str: @app.get("/api/v1/community-observations", response_model=list[PublicObservationOut]) def community_observations( db: Db, limit: int = Query(12, ge=1, le=50), offset: int = Query(0, ge=0), + waterbody: str | None = None, fish: str | None = None, ) -> list[PublicObservationOut]: - items = list(db.scalars( - select(ExternalObservation) - .join(ExternalObservation.source) - .options(joinedload(ExternalObservation.source)) - .where( - ExternalObservation.catch_report_id.is_(None), - ExternalObservation.status != "rejected", - DataSource.enabled.is_(True), - ) - .order_by(ExternalObservation.last_seen_at.desc(), ExternalObservation.id.desc()) - .offset(offset).limit(limit) - )) + query = select(ExternalObservation).join(ExternalObservation.source).options(joinedload(ExternalObservation.source)).where( + ExternalObservation.catch_report_id.is_(None), + ExternalObservation.status != "rejected", + DataSource.enabled.is_(True), + ) + if waterbody: + query = query.where(ExternalObservation.waterbody_name == waterbody) + if fish: + query = query.where(ExternalObservation.fish_name == fish) + items = list(db.scalars(query.order_by(ExternalObservation.last_seen_at.desc(), ExternalObservation.id.desc()).offset(offset).limit(limit))) result: list[PublicObservationOut] = [] for item in items: missing = [] @@ -318,7 +320,7 @@ def admin_diagnostics(db: Db, _: Annotated[str, Depends(_admin)]) -> JSONRespons return JSONResponse(payload, headers={"Content-Disposition": "attachment; filename=rf4spotter-diagnostics.json"}) -@app.get("/api/v1/imports", response_model=list[ImportRunOut]) +@app.get("/api/v1/imports", response_model=list[ImportRunPublicOut]) def imports(db: Db, limit: int = Query(20, ge=1, le=100), offset: int = Query(0, ge=0)) -> list[OfficialRecordImport]: return list(db.scalars(select(OfficialRecordImport).order_by(OfficialRecordImport.started_at.desc(), OfficialRecordImport.id.desc()).offset(offset).limit(limit))) @@ -448,7 +450,7 @@ def admin_reject_external_observation( def create_catch_report(payload: CatchReportCreate, request: Request, db: Db) -> CatchReportAccepted: if payload.website: raise HTTPException(status_code=400, detail="invalid submission") - _check_rate_limit(request.client.host if request.client else "unknown", db) + _check_rate_limit(request, db) fish = db.scalar(select(Fish).where(Fish.slug == payload.fish_slug)) waterbody = db.scalar(select(Waterbody).where(Waterbody.slug == payload.waterbody_slug)) if fish is None or waterbody is None: @@ -536,9 +538,14 @@ def delete_report(report_id: UUID, db: Db, moderator: Annotated[str, Depends(_ad return Response(status_code=204) -def _check_rate_limit(client: str, db: Session) -> None: +def _check_rate_limit(request: Request, db: Session) -> None: now = datetime.now(timezone.utc) cutoff = now - timedelta(minutes=10) + # Extract real client IP from forwarded headers + client = request.client.host if request.client else "unknown" + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + client = forwarded.split(",")[0].strip() client_hash = hmac.new(settings.rate_limit_secret.encode(), client.encode(), hashlib.sha256).hexdigest() if db.get_bind().dialect.name == "postgresql": lock_key = int(client_hash[:16], 16) & 0x7FFF_FFFF_FFFF_FFFF diff --git a/apps/api/app/readiness.py b/apps/api/app/readiness.py index b49c741..fe85fec 100644 --- a/apps/api/app/readiness.py +++ b/apps/api/app/readiness.py @@ -6,12 +6,13 @@ from typing import Any from sqlalchemy import select, text from sqlalchemy.orm import Session -from .models import ImportStatus, OfficialRecordImport +from .models import CommunityImportRun, ImportStatus, OfficialRecordImport def readiness_report( session: Session, s3: Any, *, import_required: bool, - import_interval_seconds: int, now: datetime | None = None, + import_interval_seconds: int, community_import_interval_seconds: int = 1800, + now: datetime | None = None, ) -> tuple[bool, dict[str, dict[str, object]]]: current = now or datetime.now(timezone.utc) components: dict[str, dict[str, object]] = {} @@ -58,4 +59,26 @@ def readiness_report( if import_required: ready = False + # Check community scheduler: look for recent import runs + try: + latest_community = session.scalar( + select(CommunityImportRun) + .order_by(CommunityImportRun.started_at.desc()) + .limit(1) + ) + if latest_community is None: + components["community_scheduler"] = {"status": "not_started"} + else: + started = latest_community.started_at + if started.tzinfo is None: + started = started.replace(tzinfo=timezone.utc) + stale = started < current - timedelta(seconds=community_import_interval_seconds * 2) + healthy = latest_community.status == "success" and not stale + components["community_scheduler"] = { + "status": "ready" if healthy else ("stale" if stale else latest_community.status), + "last_started_at": started.isoformat(), + } + except Exception: + components["community_scheduler"] = {"status": "unknown"} + return ready, components diff --git a/apps/api/app/schemas.py b/apps/api/app/schemas.py index a33c6c1..0ce6a3d 100644 --- a/apps/api/app/schemas.py +++ b/apps/api/app/schemas.py @@ -50,6 +50,13 @@ class ActivityOut(BaseModel): sources: list[str] +class PaginatedActivityOut(BaseModel): + items: list[ActivityOut] + total: int + limit: int + offset: int + + class CatchOut(BaseModel): id: UUID fish: str @@ -125,6 +132,18 @@ class ImportRunOut(BaseModel): not_modified: bool +class ImportRunPublicOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: UUID + started_at: datetime + finished_at: datetime | None + status: str + rows_seen: int + rows_created: int + rows_updated: int + not_modified: bool + + class CatchReportCreate(BaseModel): fish_slug: str waterbody_slug: str diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index e9e3aa0..5b5ac18 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -6,6 +6,13 @@ export type Activity = { explanation: string; sources: string[]; }; +export type PaginatedActivity = { + items: Activity[]; + total: number; + limit: number; + offset: number; +}; + export type Spot = { id: string; waterbody_slug: string; waterbody: string; x: number; y: number; description: string | null; catches_24h: number; catches_3d: number; catches_7d: number; top_baits: string[] }; export type Catch = { id: string; fish: string; weight_g: number; bait: string | null; player_name: string | null; caught_at: string | null; reported_at: string; retrieve_method: string | null; retrieve_speed: number | null; source_system: string; source_url: string | null }; export type DictionaryItem = { id: string; slug: string; name_ru: string }; diff --git a/apps/web/src/pages/api/report-screenshot.ts b/apps/web/src/pages/api/report-screenshot.ts index 4e076b1..5c42faa 100644 --- a/apps/web/src/pages/api/report-screenshot.ts +++ b/apps/web/src/pages/api/report-screenshot.ts @@ -11,7 +11,7 @@ export const POST: APIRoute = async ({ request, redirect, cookies }) => { if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(reportId) || !uploadToken || !(screenshot instanceof File) || screenshot.size === 0) return redirect(`/report?state=screenshot_error&report_id=${encodeURIComponent(reportId)}`, 303); try { const upload = new FormData(); upload.set("screenshot", screenshot); - const response = await fetch(`${base}/api/v1/catch-reports/${reportId}/screenshot`, { method: "POST", headers:{"X-Upload-Token":uploadToken}, body: upload }); + const response = await fetch(`${base}/api/v1/catch-reports/${reportId}/screenshot`, { method: "POST", headers:{"X-Upload-Token":uploadToken}, body: upload, signal: AbortSignal.timeout(60_000) }); if (response.ok) cookies.delete(cookieName, {path:"/"}); return redirect(response.ok ? "/report?state=screenshot_sent" : `/report?state=screenshot_error&report_id=${encodeURIComponent(reportId)}`, 303); } catch { return redirect(`/report?state=screenshot_error&report_id=${encodeURIComponent(reportId)}`, 303); } diff --git a/apps/web/src/pages/api/report.ts b/apps/web/src/pages/api/report.ts index 18e8ec9..76ffdc6 100644 --- a/apps/web/src/pages/api/report.ts +++ b/apps/web/src/pages/api/report.ts @@ -1,14 +1,19 @@ import type { APIRoute } from "astro"; const base = process.env.API_INTERNAL_URL || import.meta.env.API_INTERNAL_URL || "http://localhost:8000"; export const POST: APIRoute = async ({ request, redirect, cookies }) => { - const form = await request.formData(); + let form: FormData; + try { + form = await request.formData(); + } catch { + return redirect("/report?state=create_error", 303); + } const text = (name: string) => String(form.get(name) || "").trim() || null; const number = (name: string) => text(name) ? Number(text(name)) : null; const payload = { fish_slug: String(form.get("fish_slug") || ""), waterbody_slug: String(form.get("waterbody_slug") || ""), x: Number(form.get("x")), y: Number(form.get("y")), weight_g: Number(form.get("weight_g")), bait_name: text("bait_name"), fishing_method: text("fishing_method"), retrieve_method: text("retrieve_method"), retrieve_speed: number("retrieve_speed"), player_name: text("player_name"), comment: text("comment"), website: String(form.get("website") || "") }; let createdId: string | null = null; let uploadToken: string | null = null; try { - const response = await fetch(`${base}/api/v1/catch-reports`, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(payload) }); + const response = await fetch(`${base}/api/v1/catch-reports`, { method: "POST", headers: { "content-type": "application/json", "X-Forwarded-For": request.headers.get("x-forwarded-for") || request.headers.get("x-real-ip") || "unknown" }, body: JSON.stringify(payload), signal: AbortSignal.timeout(30_000) }); if (!response.ok) return redirect("/report?state=create_error", 303); const created = await response.json() as { id?: unknown; screenshot_upload_token?: unknown }; if (typeof created.id !== "string" || typeof created.screenshot_upload_token !== "string") return redirect("/report?state=create_error", 303); @@ -17,7 +22,7 @@ export const POST: APIRoute = async ({ request, redirect, cookies }) => { const screenshot = form.get("screenshot"); if (screenshot instanceof File && screenshot.size > 0) { const upload = new FormData(); upload.set("screenshot", screenshot); - const uploaded = await fetch(`${base}/api/v1/catch-reports/${created.id}/screenshot`, { method: "POST", headers: {"X-Upload-Token": created.screenshot_upload_token}, body: upload }); + const uploaded = await fetch(`${base}/api/v1/catch-reports/${created.id}/screenshot`, { method: "POST", headers: {"X-Upload-Token": created.screenshot_upload_token}, body: upload, signal: AbortSignal.timeout(60_000) }); if (!uploaded.ok) { cookies.set(`rf4-upload-${created.id}`, created.screenshot_upload_token, {httpOnly:true, sameSite:"strict", secure:import.meta.env.PROD, path:"/", maxAge:3600}); return redirect(`/report?state=screenshot_error&report_id=${encodeURIComponent(created.id)}`, 303); diff --git a/apps/web/src/pages/index.astro b/apps/web/src/pages/index.astro index d4cc3e9..e9fc387 100644 --- a/apps/web/src/pages/index.astro +++ b/apps/web/src/pages/index.astro @@ -4,7 +4,7 @@ import ActivityCard from "../components/ActivityCard.astro"; import FishingIcon from "../components/FishingIcon.astro"; import SourceBadge from "../components/SourceBadge.astro"; import SignalFeed from "../components/SignalFeed.astro"; -import { activityLevel, ago, api, kg, plural, type Activity, type DictionaryItem, type PublicObservation } from "../lib/api"; +import { activityLevel, ago, api, kg, plural, type Activity, type DictionaryItem, type PaginatedActivity, type PublicObservation } from "../lib/api"; const params = Astro.url.searchParams; const hours = params.get("hours") ?? "24"; @@ -14,17 +14,22 @@ const sort = params.get("sort") ?? "activity"; const requestedSignalLimit = Number(params.get("signals") ?? 12); const signalLimit = Number.isInteger(requestedSignalLimit) ? Math.min(48, Math.max(12, requestedSignalLimit)) : 12; let items: Activity[] = [], signals: PublicObservation[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = []; -let hasMoreSignals = false; +let hasMoreSignals = false, totalItems = 0; const filterError = !["6", "12", "24", "72"].includes(hours) || !["activity", "confidence", "freshness"].includes(sort); let unavailable = false; try { let signalRows: PublicObservation[] = []; - [fishes, waterbodies, signalRows] = await Promise.all([api("/api/v1/fishes"), api("/api/v1/waterbodies"), api(`/api/v1/community-observations?limit=${signalLimit + 1}`)]); + const signalParams = new URLSearchParams({ limit: String(signalLimit + 1) }); + if (waterbody) signalParams.set("waterbody", waterbody); + if (fish) signalParams.set("fish", fish); + [fishes, waterbodies, signalRows] = await Promise.all([api("/api/v1/fishes"), api("/api/v1/waterbodies"), api(`/api/v1/community-observations?${signalParams}`)]); hasMoreSignals = signalRows.length > signalLimit; signals = signalRows.slice(0, signalLimit); if (!filterError) { - const query = new URLSearchParams({ hours, waterbody, fish, sort }); - items = await api(`/api/v1/activity?${query}`); + const query = new URLSearchParams({ hours, waterbody, fish, sort, limit: "20", offset: "0" }); + const paginated = await api(`/api/v1/activity?${query}`); + items = paginated.items; + totalItems = paginated.total; } } catch { unavailable = true; } const leaderLevel = items.length > 1 && items[0] ? activityLevel(items[0].activity_score) : null; @@ -49,25 +54,28 @@ const datasetJsonLd = {
-
Период и порядок {periodLabel}
- - -
+ +
{selectedWaterbody}{selectedFish}{periodLabel}{sortLabel}{filtersChanged && Сбросить}
-
За выбранный период

Горячие точки

{items.length} {plural(items.length, ["точка", "точки", "точек"])}
{filterError ?

Некорректные фильтры

Выберите период и сортировку из предложенных значений.

Сбросить фильтры
: unavailable ?

Источник временно недоступен

Не показываем устаревшие догадки. Попробуйте позже.

: items.length ?
{items.map(item => )}
:

Пока нет свежих данных

Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.

}
+
За выбранный период

Горячие точки

{items.length} из {totalItems} {plural(totalItems, ["точка", "точки", "точек"])}
{filterError ?

Некорректные фильтры

Выберите период и сортировку из предложенных значений.

Сбросить фильтры
: unavailable ?

Источник временно недоступен

Не показываем устаревшие догадки. Попробуйте позже.

: items.length ? <>
{items.map(item => )}
{items.length < totalItems && Показать ещё {items.length} из {totalItems}} :

Пока нет свежих данных

Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.

}
{items[0] && leaderLevel && }
{signals.length > 0 && } {hasMoreSignals && signalLimit < 48 && Показать ещё {signalLimit} из доступных}
Как читать данные

Не обещаем рыбу.
Показываем факты.

01

Свежесть

Чем старше сообщение, тем меньше оно влияет на активность.

02

Разные игроки

Десять уловов одного человека не равны десяти подтверждениям.

03

Уверенность

Каждая оценка объясняет, сколько данных за ней стоит.