Compare commits

..
5 Commits
Author SHA1 Message Date
ik 0f9c255596 feat: group duplicate field signals
CI / backend-and-migrations (push) Canceled after 0s
CI / astro-build (push) Canceled after 0s
CI / compose-e2e (push) Canceled after 0s
2026-09-07 18:45:16 +07:00
ik 02b67741f8 feat: expose deployment build identity 2026-09-07 18:43:20 +07:00
ik ef8355904c feat: paginate public field signals 2026-09-07 17:12:41 +07:00
ik 37aa8ec60d feat: add open alpha banner 2026-09-07 17:11:08 +07:00
ik dbe01359de feat: illustrate empty states 2026-09-07 17:09:22 +07:00
16 changed files with 94 additions and 13 deletions
+2
View File
@@ -1,4 +1,6 @@
DATABASE_URL=postgresql+psycopg://rf4:rf4_local@localhost:5432/rf4_spotter
APP_VERSION=0.1.0
APP_REVISION=dev
PUBLIC_API_URL=http://localhost:8000
API_INTERNAL_URL=http://api:8000
ADMIN_TOKEN=change-me-in-production
+2
View File
@@ -8,6 +8,8 @@ POSTGRES_USER=rf4
POSTGRES_PASSWORD=replace-with-long-random-value
# URL-encode special characters from POSTGRES_PASSWORD in this URL.
DATABASE_URL=postgresql+psycopg://rf4:replace-with-url-encoded-password@db:5432/rf4_spotter
APP_VERSION=0.1.0
APP_REVISION=replace-with-git-commit-sha
ADMIN_TOKEN=replace-with-at-least-32-random-characters
RATE_LIMIT_SECRET=replace-with-at-least-32-random-characters
+5 -5
View File
@@ -6,15 +6,15 @@ RF4 Spotter — неофициальный сервис свежих точек
## Статус разработки
Функциональный MVP и локальный production-контур готовятся к открытой альфе: официальный импорт, пользовательские заявки, модерация, объяснимый индекс, staging внешних источников, адаптивный Astro UI, миграции, резервное копирование, retention, мониторинг и security/accessibility-проверки реализованы. Автоматические импорты внешних источников выключены. Публичный запуск блокируют покупка и настройка сервера, DNS/TLS, реальные секреты, внешний backup, канал уведомлений и публичные страницы правил/privacy.
Функциональный MVP и локальный production-контур готовятся к открытой альфе: официальный импорт, пользовательские заявки, модерация, объяснимый индекс, staging внешних источников, адаптивный Astro UI, миграции, резервное копирование, retention, мониторинг и security/accessibility-проверки реализованы. На всех страницах подключён компактный баннер открытой альфы со ссылками на статус, правила и отправку улова. Автоматические импорты внешних источников выключены. Публичный запуск блокируют покупка и настройка сервера, DNS/TLS, реальные секреты, внешний backup, канал уведомлений и публичные страницы правил/privacy; публичный адрес обратной связи ещё не задан.
RF4DB/RF4-STAT/RF4MAP/RF4 Posts сначала принимаются в изолированный staging. Полные записи с ранее подтверждёнными алиасами источника публикуются автоматически; новые соответствия и неполные записи остаются на ручной проверке. Для разрешённых community-источников действует интервал не менее 30 минут на источник. Открытая альфа не использует продуктовый allowlist: интерфейс показывает весь корректно загруженный разрешённый каталог, сохраняя требования полноты и модерации.
На сайте у каждой записи отображается источник, а у агрегированной активности — все вошедшие в расчёт источники. Неполные community-наблюдения публикуются сразу в отдельной ленте «Полевые сигналы» с предупреждением и перечнем отсутствующих полей; до подтверждения полноты они не влияют на индекс клёва.
На сайте у каждой записи отображается источник, а у агрегированной активности — все вошедшие в расчёт источники. Неполные community-наблюдения публикуются сразу в отдельной ленте «Полевые сигналы» с предупреждением и перечнем отсутствующих полей; до подтверждения полноты они не влияют на индекс клёва. Лента раскрывается серверной кнопкой «Показать ещё», сохраняет выбранные фильтры и ограничена 48 сигналами на страницу. Совпадающие рыба, водоём, координаты и вес объединяются визуально, при этом карточка сохраняет все уникальные ссылки на исходные наблюдения.
Базовый SEO-контур готов для `rf4spotter.ru`: страницы имеют уникальные метаданные, canonical, Open Graph/Twitter Card, фирменное изображение 1200×630 и JSON-LD; доступны динамические `/robots.txt` и `/sitemap.xml`, административные и ошибочные страницы закрыты от индексации, добавлена собственная страница 404. Индексируемые каталоги рыб и водоёмов, detail-страницы и сочетания водоём + рыба строятся из актуального разрешённого справочника и включаются в sitemap. Подключены фирменные favicon/app icons, web manifest и production-кэширование статических ресурсов. Карточки активности показывают единый паспорт данных: источники, свежесть, полноту и уровень доверия. На `/status` опубликована легенда цветов всех источников и статусов качества.
Публичные точки используют постоянные читаемые адреса вида `/spots/kuori-85x92`; старые UUID-адреса остаются совместимыми и перенаправляются на канонический URL. На странице точки координаты дополнительно показаны фирменным радаром, который не имитирует отсутствующую географию водоёма, а уловы за 72 часа — шкалой-леской с 12-часовым шагом. Карточки активности и каталог дополнены лёгкими SVG-силуэтами рыб без внешних графических зависимостей.
Публичные точки используют постоянные читаемые адреса вида `/spots/kuori-85x92`; старые UUID-адреса остаются совместимыми и перенаправляются на канонический URL. На странице точки координаты дополнительно показаны фирменным радаром, который не имитирует отсутствующую географию водоёма, а уловы за 72 часа — шкалой-леской с 12-часовым шагом. Карточки активности и каталог дополнены лёгкими SVG-силуэтами рыб без внешних графических зависимостей. Пустые и аварийные состояния используют собственную CSS-иллюстрацию поплавка; анимация учитывает системное ограничение движения.
Все пять community-парсеров подключены к отдельному scheduler-процессу. Состояние запусков и ошибок хранится в PostgreSQL, параллельный запуск одного источника блокируется, минимальный интервал жёстко ограничен 1800 секундами. Локально процесс включается профилем `docker compose --profile scheduler up -d`; detail-URL RF4MAP/RF4 Posts задаются переменными окружения.
@@ -83,10 +83,10 @@ docker compose up --build
- сайт: <http://localhost:4321>;
- OpenAPI: <http://localhost:8000/docs>;
- liveness API: <http://localhost:8000/health>;
- readiness PostgreSQL, MinIO и импорта: <http://localhost:8000/ready>;
- readiness PostgreSQL, MinIO и импорта с версией/revision сборки: <http://localhost:8000/ready>;
- консоль MinIO: <http://localhost:9001>.
Контейнер API сам выполняет `alembic upgrade head`, затем идемпотентный seed. PostgreSQL хранит данные в именованном volume `postgres_data`, а MinIO — в `minio_data`. Compose ожидает readiness PostgreSQL и MinIO перед API, а API-контейнер проверяет `/ready`. Официальный импорт по умолчанию необязателен; при включённом scheduler установите `OFFICIAL_IMPORT_REQUIRED=true`, тогда отсутствующий, неуспешный или просроченный запуск сделает readiness отрицательным.
Контейнер API сам выполняет `alembic upgrade head`, затем идемпотентный seed. PostgreSQL хранит данные в именованном volume `postgres_data`, а MinIO — в `minio_data`. Compose ожидает readiness PostgreSQL и MinIO перед API, а API-контейнер проверяет `/ready`. Версия и commit SHA задаются через `APP_VERSION`/`APP_REVISION`; те же значения доступны администратору в `/api/v1/admin/diagnostics`. Официальный импорт по умолчанию необязателен; при включённом scheduler установите `OFFICIAL_IMPORT_REQUIRED=true`, тогда отсутствующий, неуспешный или просроченный запуск сделает readiness отрицательным.
API и scheduler пишут по одной JSON-записи на событие. HTTP-лог содержит только сгенерированный `request_id`, метод, путь без query string, статус и длительность; IP, заголовок авторизации и пользовательский payload не журналируются. `X-Request-ID` возвращается клиенту. Стандартный access-log Uvicorn отключён. Уровень управляется `LOG_LEVEL`.
+2
View File
@@ -4,6 +4,8 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
deployment_environment: str = "development"
app_version: str = "0.1.0"
app_revision: str = "dev"
database_url: str = "postgresql+psycopg://rf4:rf4_local@localhost:5432/rf4_spotter"
admin_token: str = "change-me-in-production"
s3_endpoint_url: str = "http://localhost:9000"
+6 -1
View File
@@ -89,7 +89,7 @@ def ready(db: Db) -> JSONResponse:
)
return JSONResponse(
status_code=200 if is_ready else 503,
content={"status": "ready" if is_ready else "not_ready", "components": components},
content={"status": "ready" if is_ready else "not_ready", "version": settings.app_version, "revision": settings.app_revision, "components": components},
)
@@ -257,6 +257,11 @@ def _admin(authorization: Annotated[str | None, Header()] = None) -> str:
return "admin"
@app.get("/api/v1/admin/diagnostics")
def admin_diagnostics(_: Annotated[str, Depends(_admin)]) -> dict[str, str]:
return {"version": settings.app_version, "revision": settings.app_revision, "environment": settings.deployment_environment}
@app.get("/api/v1/imports", response_model=list[ImportRunOut])
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)))
+7
View File
@@ -85,6 +85,13 @@ def test_liveness_does_not_probe_dependencies() -> None:
assert response.headers["Cross-Origin-Opener-Policy"] == "same-origin"
def test_admin_diagnostics_exposes_build_identity_only_to_admin() -> None:
assert client.get("/api/v1/admin/diagnostics").status_code == 401
response = client.get("/api/v1/admin/diagnostics", headers={"Authorization": "Bearer change-me-in-production"})
assert response.status_code == 200
assert response.json() == {"version": "0.1.0", "revision": "dev", "environment": "development"}
def test_spot_detail_and_catches() -> None:
spot_id = client.get("/api/v1/activity").json()[0]["spot_id"]
detail = client.get(f"/api/v1/spots/{spot_id}")
@@ -0,0 +1,4 @@
<aside class="alpha-banner" aria-label="Информация об открытой альфе">
<div><strong><i></i>Открытая альфа</strong><span>Данные могут быть неполными — источник и качество всегда указаны.</span></div>
<nav aria-label="Ссылки открытой альфы"><a href="/status">Статус данных</a><a href="/rules">Как это работает</a><a class="alpha-banner__cta" href="/report">Добавить улов</a></nav>
</aside>
+34
View File
@@ -0,0 +1,34 @@
---
import SourceBadge from "./SourceBadge.astro";
import { ago, kg, type PublicObservation } from "../lib/api";
const { signals } = Astro.props as { signals: PublicObservation[] };
const groups = [...signals.reduce((map, signal) => {
const key = [signal.fish_name, signal.waterbody_name, signal.x, signal.y, signal.weight_g].join("|").toLocaleLowerCase("ru");
const current = map.get(key);
if (!current) map.set(key, { ...signal, observations: [signal] });
else {
current.observations.push(signal);
current.last_seen_at = new Date(signal.last_seen_at) > new Date(current.last_seen_at) ? signal.last_seen_at : current.last_seen_at;
current.missing_fields = [...new Set([...current.missing_fields, ...signal.missing_fields])];
if (signal.quality === "incomplete") current.quality = "incomplete";
}
return map;
}, new Map<string, PublicObservation & { observations: PublicObservation[] }>()).values()];
---
<section class="signal-section content-grid" aria-labelledby="signals-title">
<div class="signal-heading"><div><span class="overline">Сырые данные источников</span><h2 id="signals-title">Полевые сигналы</h2><p>Показываем сразу, даже если часть полей отсутствует. Эти карточки не участвуют в расчёте активности до полного подтверждения.</p></div><span class="signal-count">{signals.length} сигналов · {groups.length} сюжетов</span></div>
<div class="signal-grid">{groups.map(signal => {
const provenance = [...new Map(signal.observations.map(item => [`${item.source_system}|${item.source_url}`, item])).values()];
return <article class="signal-card" data-quality={signal.quality}>
<div class="signal-card__top"><div class="source-strip">{provenance.map(item => <SourceBadge source={item.source_system} href={item.source_url}/>)}</div><span class="quality-chip"><i>{signal.quality === "incomplete" ? "!" : "?"}</i> {signal.quality === "incomplete" ? "Неполные данные" : "Ждёт проверки"}</span></div>
<h3>{signal.fish_name}</h3><p>{signal.waterbody_name}</p>
<dl><div><dt>Точка</dt><dd>{signal.x == null || signal.y == null ? "не указана" : `${signal.x}:${signal.y}`}</dd></div><div><dt>Вес</dt><dd>{signal.weight_g == null ? "не указан" : kg(signal.weight_g)}</dd></div><div><dt>Получено</dt><dd>{ago(signal.last_seen_at)}</dd></div></dl>
{signal.observations.length > 1 && <p class="repeat-note">Совпали {signal.observations.length} наблюдения · все источники сохранены</p>}
<p class="missing-note"><span>{signal.missing_fields.length ? "Не хватает:" : "Статус:"}</span> {signal.missing_fields.join(", ") || "проверяется соответствие справочнику"}</p>
</article>;
})}</div>
</section>
<style>
.repeat-note{position:relative;z-index:1;margin:-8px 0 10px!important;padding:6px 9px;border-left:2px solid #6b8d74;color:#496357!important;font-size:10px!important}
.signal-card__top .source-strip{margin:0}
</style>
+5
View File
@@ -7,7 +7,11 @@ import "../styles/data-legend.css";
import "../styles/coordinate-radar.css";
import "../styles/activity-timeline.css";
import "../styles/fish-silhouette.css";
import "../styles/empty-states.css";
import "../styles/alpha-banner.css";
import "../styles/signal-pagination.css";
import FishingIcon from "../components/FishingIcon.astro";
import AlphaBanner from "../components/AlphaBanner.astro";
const {
title = "RF4 Spotter — свежие точки клёва Russian Fishing 4",
description = "Свежие точки, уловы и честная оценка активности в Russian Fishing 4 с обязательным указанием источников.",
@@ -65,6 +69,7 @@ const jsonLd = JSON.stringify({
<nav aria-label="Разделы сайта"><a class:list={{active:path === "/"}} aria-current={path === "/" ? "page" : undefined} href="/"><FishingIcon name="float"/> <span>Сейчас клюёт</span></a><a class:list={{active:path.startsWith("/waterbodies") || path.startsWith("/fish")}} aria-current={path.startsWith("/waterbodies") || path.startsWith("/fish") ? "page" : undefined} href="/waterbodies"><FishingIcon name="ripple"/> <span>Каталог</span></a><a class:list={{active:path.startsWith("/records")}} aria-current={path.startsWith("/records") ? "page" : undefined} href="/records"><FishingIcon name="trophy"/> <span>Рекорды</span></a><a class:list={{active:path.startsWith("/report")}} aria-current={path.startsWith("/report") ? "page" : undefined} href="/report"><FishingIcon name="plus"/> <span>Добавить улов</span></a></nav>
<p class="live-badge"><span></span> Свежие данные и честная оценка</p>
</header>
<AlphaBanner />
<main id="main-content" tabindex="-1"><slot /></main>
<footer><a href="/" class="brand"><span class="brand-mark"><FishingIcon name="hook" size={24}/></span><span class="brand-name"><span>Ни хвоста,</span><strong>ни чешуи</strong></span></a><p>Неофициальный проект для игроков Russian Fishing 4. <a href="/status">Статус</a> · <a href="/rules">Правила</a> · <a href="/privacy">Конфиденциальность</a></p><span>НХНЧ · 2026</span></footer>
</body>
+13 -2
View File
@@ -3,6 +3,7 @@ import Layout from "../layouts/Layout.astro";
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";
const params = Astro.url.searchParams;
@@ -10,11 +11,17 @@ const hours = params.get("hours") ?? "24";
const waterbody = params.get("waterbody") ?? "";
const fish = params.get("fish") ?? "";
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;
const filterError = !["6", "12", "24", "72"].includes(hours) || !["activity", "confidence", "freshness"].includes(sort);
let unavailable = false;
try {
[fishes, waterbodies, signals] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies"), api<PublicObservation[]>("/api/v1/community-observations?limit=12")]);
let signalRows: PublicObservation[] = [];
[fishes, waterbodies, signalRows] = await Promise.all([api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies"), api<PublicObservation[]>(`/api/v1/community-observations?limit=${signalLimit + 1}`)]);
hasMoreSignals = signalRows.length > signalLimit;
signals = signalRows.slice(0, signalLimit);
if (!filterError) {
const query = new URLSearchParams({ hours, waterbody, fish, sort });
items = await api<Activity[]>(`/api/v1/activity?${query}`);
@@ -26,6 +33,9 @@ const selectedFish = fishes.find(item => item.slug === fish)?.name_ru ?? "Люб
const periodLabel = { "6": "6 часов", "12": "12 часов", "24": "24 часа", "72": "72 часа" }[hours] ?? hours;
const sortLabel = { activity: "Сначала активные", confidence: "Сначала надёжные", freshness: "Сначала свежие" }[sort] ?? sort;
const filtersChanged = Boolean(waterbody || fish || hours !== "24" || sort !== "activity");
const moreSignalParams = new URLSearchParams(params);
moreSignalParams.set("signals", String(Math.min(48, signalLimit + 12)));
const moreSignalsHref = `/?${moreSignalParams.toString()}#signals-title`;
const datasetJsonLd = {
"@context": "https://schema.org", "@type": "Dataset",
name: "RF4 Spotter — наблюдения об активности рыбы",
@@ -49,7 +59,8 @@ const datasetJsonLd = {
<section class="dashboard content-grid" id="results"><div class="results-column"><div class="section-heading"><div><span class="overline">За выбранный период</span><h2>Горячие точки</h2></div><span class="result-count">{items.length} {plural(items.length, ["точка", "точки", "точек"])}</span></div>{filterError ? <div class="state error-state"><h2>Некорректные фильтры</h2><p>Выберите период и сортировку из предложенных значений.</p><a href="/">Сбросить фильтры</a></div> : unavailable ? <div class="state"><h2>Источник временно недоступен</h2><p>Не показываем устаревшие догадки. Попробуйте позже.</p></div> : items.length ? <div class="spot-list">{items.map(item => <ActivityCard item={item} />)}</div> : <div class="state"><h2>Пока нет свежих данных</h2><p>Для выбранных фильтров нет одобренных наблюдений. Расширьте период или выберите другой водоём.</p></div>}</div>
{items[0] && leaderLevel && <aside class="detail-card"><div class="detail-head"><div><span class="overline">Лидер активности</span><h2>{items[0].waterbody} <em>{items[0].x}:{items[0].y}</em></h2></div><a href={`/spots/${items[0].spot_id}`} aria-label="Открыть точку"><FishingIcon name="arrow"/></a></div><div class="source-strip">{items[0].sources.map(source => <SourceBadge source={source}/>)}</div><div class="detail-score"><div class="float-gauge" style={`--level:${items[0].activity_score}%`} aria-label={`Индекс активности: ${items[0].activity_score} из 100`}><span class="float-gauge__line"></span><span class="float-gauge__water"></span><span class="float-gauge__bob"><i></i></span><strong>{items[0].activity_score}</strong><small>из 100</small></div><div><span>Индекс активности</span><strong data-activity-level={leaderLevel.short}>{leaderLevel.description}</strong><p>{items[0].explanation}</p></div></div><div class="metric-grid"><div><span><FishingIcon name="ripple"/></span><small>Уверенность</small><strong>{items[0].confidence_score}%</strong></div><div><span><FishingIcon name="angler"/></span><small>{plural(items[0].unique_players, ["Игрок", "Игрока", "Игроков"])}</small><strong>{items[0].unique_players}</strong></div><div><span><FishingIcon name="clock"/></span><small>Последний</small><strong>{ago(items[0].last_confirmed_at)}</strong></div><div><span><FishingIcon name="scale"/></span><small>Средний вес</small><strong>{kg(items[0].average_weight_g)}</strong></div></div><div class="best-lure"><span class="overline">Лучшая связка</span><div><FishingIcon name="lure" size={25}/><strong>{items[0].best_bait ?? "Не указана"}</strong><span>{items[0].catches} {plural(items[0].catches, ["улов", "улова", "уловов"])}</span></div></div><p class="confidence-note"><span>✓</span><span><strong>Оценка объяснима.</strong> Один игрок не может искусственно поднять уверенность.</span></p></aside>}
</section>
{signals.length > 0 && <section class="signal-section content-grid" aria-labelledby="signals-title"><div class="signal-heading"><div><span class="overline">Сырые данные источников</span><h2 id="signals-title">Полевые сигналы</h2><p>Показываем сразу, даже если часть полей отсутствует. Эти карточки не участвуют в расчёте активности до полного подтверждения.</p></div><span class="signal-count">{signals.length} новых</span></div><div class="signal-grid">{signals.map(signal => <article class="signal-card" data-quality={signal.quality}><div class="signal-card__top"><SourceBadge source={signal.source_system} href={signal.source_url}/><span class="quality-chip"><i>{signal.quality === "incomplete" ? "!" : "?"}</i> {signal.quality === "incomplete" ? "Неполные данные" : "Ждёт проверки"}</span></div><h3>{signal.fish_name}</h3><p>{signal.waterbody_name}</p><dl><div><dt>Точка</dt><dd>{signal.x == null || signal.y == null ? "не указана" : `${signal.x}:${signal.y}`}</dd></div><div><dt>Вес</dt><dd>{signal.weight_g == null ? "не указан" : kg(signal.weight_g)}</dd></div><div><dt>Получено</dt><dd>{ago(signal.last_seen_at)}</dd></div></dl><p class="missing-note"><span>{signal.missing_fields.length ? "Не хватает:" : "Статус:"}</span> {signal.missing_fields.join(", ") || "проверяется соответствие справочнику"}</p></article>)}</div></section>}
{signals.length > 0 && <SignalFeed signals={signals}/>}
{hasMoreSignals && signalLimit < 48 && <a class="signal-more" href={moreSignalsHref}>Показать ещё <span>{signalLimit} из доступных</span> ↓</a>}
<section class="how-it-works content-grid"><div><span class="overline">Как читать данные</span><h2>Не обещаем рыбу.<br/>Показываем факты.</h2></div><div class="principles"><article><span>01</span><h3>Свежесть</h3><p>Чем старше сообщение, тем меньше оно влияет на активность.</p></article><article><span>02</span><h3>Разные игроки</h3><p>Десять уловов одного человека не равны десяти подтверждениям.</p></article><article><span>03</span><h3>Уверенность</h3><p>Каждая оценка объясняет, сколько данных за ней стоит.</p></article></div></section>
<script>
const advancedFilters = document.querySelector<HTMLDetailsElement>(".filter-advanced");
+1
View File
@@ -0,0 +1 @@
.alpha-banner{width:min(1480px,calc(100% - 48px));min-height:39px;margin:0 auto 8px;padding:7px 9px 7px 13px;display:flex;align-items:center;justify-content:space-between;gap:18px;border:1px solid #c7d3ca;border-radius:11px;background:linear-gradient(90deg,#e8efdf,#f7f9f3);color:#405552;font-size:11px}.alpha-banner>div,.alpha-banner strong,.alpha-banner nav{display:flex;align-items:center;gap:10px}.alpha-banner strong{text-transform:uppercase;letter-spacing:.08em;color:var(--deep);white-space:nowrap}.alpha-banner strong i{width:7px;height:7px;border-radius:50%;background:#789f35;box-shadow:0 0 0 4px #789f3518}.alpha-banner nav a{text-underline-offset:3px;white-space:nowrap}.alpha-banner__cta{padding:6px 10px;border-radius:7px;background:var(--deep);color:#fff;text-decoration:none}@media(max-width:720px){.alpha-banner{width:calc(100% - 28px);margin-top:9px}.alpha-banner>div>span,.alpha-banner nav a:not(.alpha-banner__cta){display:none}}
+2
View File
@@ -0,0 +1,2 @@
.state{position:relative;overflow:hidden;padding:34px}.state:before{content:"";width:22px;height:52px;margin-bottom:18px;border:1px solid #56726d;border-radius:50% 50% 42% 42%;background:linear-gradient(180deg,#ff785a 0 30%,#fff 31% 55%,var(--lime) 56%);box-shadow:inset -5px 0 #173c3f18;transform:rotate(3deg)}.state:after{content:"";position:absolute;width:88px;height:18px;left:50%;top:89px;translate:-50% 0;border:1px solid #648784;border-width:1px 0 0;border-radius:50%;box-shadow:0 -7px 0 -6px #648784,0 7px 0 -6px #648784;opacity:.55}.state.compact-state:before{width:17px;height:40px;margin-bottom:11px}.state.compact-state:after{top:60px;width:68px}.state.error-state:before{filter:grayscale(.35);opacity:.7}
@media(prefers-reduced-motion:no-preference){.state:before{animation:empty-bob 3s ease-in-out infinite}.state:after{animation:empty-ripple 3s ease-out infinite}}@keyframes empty-bob{50%{translate:0 -4px;rotate:-4deg}}@keyframes empty-ripple{50%{scale:1.12 .9;opacity:.25}}
@@ -0,0 +1 @@
.signal-more{width:max-content;display:flex;align-items:center;gap:9px;margin:-72px auto 82px;padding:12px 18px;border:1px solid #9caf9f;border-radius:999px;background:#f8faf5;color:var(--deep);font-size:12px;font-weight:750;text-decoration:none;box-shadow:0 8px 22px #1433380c}.signal-more span{color:#6f817d;font-weight:400}.signal-more:hover{border-color:#617d70;background:#fff}@media(max-width:720px){.signal-more{margin-top:-72px}}
+2
View File
@@ -105,6 +105,8 @@ services:
restart: unless-stopped
environment:
DEPLOYMENT_ENVIRONMENT: production
APP_VERSION: ${APP_VERSION:-0.1.0}
APP_REVISION: ${APP_REVISION:-unknown}
DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL}
ADMIN_TOKEN: ${ADMIN_TOKEN:?Set ADMIN_TOKEN}
CORS_ORIGINS: '["https://${SITE_DOMAIN:?Set SITE_DOMAIN}"]'
+2
View File
@@ -31,6 +31,8 @@ docker run --rm caddy:2.10.2-alpine caddy hash-password --plaintext 'ОТДЕЛ
./deploy/preflight.sh
```
Перед сборкой запишите текущий `git rev-parse --short HEAD` в `APP_REVISION` файла `.env.production`, чтобы `/ready` однозначно показывал развёрнутый commit.
```bash
docker compose --env-file .env.production -f compose.production.yaml config --quiet
docker compose --env-file .env.production -f compose.production.yaml build
+6 -5
View File
@@ -130,8 +130,9 @@
- [x] Собрать единый визуальный «паспорт данных»: источник, свежесть, полнота и доверие; применить к карточкам активности (7 сентября 2026).
- [x] Добавить публичную легенду цветов источников и статусов качества на странице `/status` (7 сентября 2026).
- [ ] Добавить skeleton-состояния для динамических публичных блоков.
- [ ] Добавить ненавязчивый баннер открытой альфы и канал обратной связи.
- [ ] Дополнить пустые состояния собственными рыболовными иллюстрациями с поддержкой `prefers-reduced-motion`.
- [x] Добавить ненавязчивый глобальный баннер открытой альфы со ссылками на статус, правила и отправку улова (7 сентября 2026).
- [ ] Подключить к баннеру публичный канал обратной связи после получения его адреса; не раскрывать частный Gitea.
- [x] Дополнить пустые состояния собственной CSS-иллюстрацией поплавка и ряби с поддержкой `prefers-reduced-motion` (7 сентября 2026).
### Производительность, данные и эксплуатация
@@ -140,12 +141,12 @@
- [x] Показывать на `/status` безопасное состояние источников: актуален, устарел, временно ограничен, изменился DOM, ожидает запуска или выключен (7 сентября 2026).
- [ ] Добавить короткое серверное кэширование публичных GET API и проверить корректную инвалидацию после публикации.
- [x] Настроить долгий immutable cache для хешированных assets и разумный cache для изображений/favicon (7 сентября 2026).
- [ ] Добавить пагинацию или «Показать ещё» для публичной ленты полевых сигналов.
- [ ] Ограничить визуальные повторы одинаковых сигналов без потери provenance.
- [x] Добавить серверное «Показать ещё» для публичной ленты полевых сигналов с сохранением фильтров и пределом 48 записей (7 сентября 2026).
- [x] Объединять одинаковые полевые сигналы в сюжеты без потери уникальных ссылок provenance и показывать число совпадений (7 сентября 2026).
- [ ] Добавить безопасный административный экспорт диагностики без персональных данных.
- [ ] Контролировать рост PostgreSQL и MinIO с порогами предупреждений.
- [ ] Добавить фоновую проверку битых исходных ссылок с соблюдением лимитов источников.
- [ ] Показывать commit SHA/версию в readiness и административной диагностике.
- [x] Показывать версию и commit SHA в readiness и защищённой административной диагностике (7 сентября 2026).
- [x] Добавить публичную `/status` без внутренних адресов, секретов и текстов ошибок (7 сентября 2026).
- [ ] После появления сервера подключить privacy-friendly аналитику без cookies либо собственные агрегированные счётчики.
- [ ] Зафиксировать нагрузочный бюджет и проверить p95 публичных API на целевом сервере.