Compare commits
6
Commits
aeef99920a
...
0b2c7f719f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b2c7f719f | ||
|
|
2aa2d30750 | ||
|
|
d69ad9c01d | ||
|
|
fec830879b | ||
|
|
2b7ad4a03a | ||
|
|
62e584e8e7 |
@@ -10,5 +10,6 @@ dist/
|
||||
.astro/
|
||||
test-results/
|
||||
playwright-report/
|
||||
lighthouse-report.json
|
||||
*.egg-info/
|
||||
*.db
|
||||
|
||||
@@ -4,13 +4,9 @@ RF4 Spotter — неофициальный сервис свежих точек
|
||||
|
||||
## Статус разработки
|
||||
|
||||
- этапы 0 и 1 завершены;
|
||||
- этап 2, официальный импорт, завершён технически; автоматический профиль остаётся выключенным до явного разрешения владельца источника;
|
||||
- этап 3 функционально завершён: форма, раздельные ошибки создания/скриншота с повторной загрузкой, MinIO, модерация, удаление с аудитом и постоянный rate limit готовы;
|
||||
- для полного пользовательского сценария добавлен E2E-тест `отправка → pending → модерация → публичная статистика`;
|
||||
- начат этап 4: формула индекса зафиксирована, детерминированные агрегаты и правила включения данных покрыты тестами; далее — сквозная проверка фильтров;
|
||||
- RF4DB/RF4-STAT загружаются в изолированный staging; добавлены канонические алиасы и ручная очередь публикации.
|
||||
- RF4MAP и RF4 Posts разрешены для исследовательского staging с интервалом не менее 30 минут на источник; CLI обеспечивает cooldown, источники выключены и не публикуются автоматически.
|
||||
Функциональный MVP и локальный production-контур готовы к закрытой альфе: официальный импорт, пользовательские заявки, модерация, объяснимый индекс, staging внешних источников, адаптивный Astro UI, миграции, резервное копирование, retention, мониторинг и security/accessibility-проверки реализованы. Автоматические импорты внешних источников выключены. Публичный запуск блокируют покупка и настройка сервера, DNS/TLS, реальные секреты, внешний backup и канал уведомлений.
|
||||
|
||||
RF4DB/RF4-STAT/RF4MAP/RF4 Posts принимаются только в изолированный staging и не влияют на индекс без ручного сопоставления и публикации. Для разрешённых community-источников действует интервал не менее 30 минут на источник.
|
||||
|
||||
Подробный план и актуальные чекбоксы находятся в [`docs/ROADMAP.md`](docs/ROADMAP.md). Результаты проверки интерфейса и пять приоритетных UX-пакетов описаны в [`docs/UI_UX_AUDIT.md`](docs/UI_UX_AUDIT.md).
|
||||
|
||||
@@ -24,6 +20,19 @@ Host-side мониторинг контейнеров, readiness, диска, р
|
||||
Фактическое состояние DNS/TLS домена и серверный чек-лист ведутся в [`docs/deployment-status.md`](docs/deployment-status.md).
|
||||
Результаты security review и остаточные ограничения закрытой альфы записаны в [`docs/security-review.md`](docs/security-review.md).
|
||||
|
||||
## Архитектура
|
||||
|
||||
```text
|
||||
Caddy :80/:443
|
||||
├─ Astro SSR web
|
||||
├─ FastAPI /api и /health
|
||||
└─ MinIO: только health и подписанные объекты
|
||||
FastAPI ─ PostgreSQL 17
|
||||
└ MinIO/S3
|
||||
```
|
||||
|
||||
Наружу production-профиль публикует только Caddy. PostgreSQL, API, Astro и MinIO находятся во внутренних Docker-сетях. Caddy завершает TLS и защищает административные маршруты Basic Auth; административный API дополнительно требует bearer token.
|
||||
|
||||
Gitea Actions workflow `.gitea/workflows/ci.yml` на каждый push и pull request проверяет Python, миграции на чистой PostgreSQL, Astro build и полный Compose/Playwright-сценарий. При падении E2E сохраняются логи контейнеров и Playwright-артефакты.
|
||||
|
||||
Актуальная инвентаризация источников и правила подключения адаптеров находятся в [`docs/data-source-audit.md`](docs/data-source-audit.md). Разрешённый технический пилот RF4DB/RF4-STAT описан в [`docs/community-source-pilot.md`](docs/community-source-pilot.md), а статус разрешений и лимитов — в [`docs/data-permissions.md`](docs/data-permissions.md). Данные сохраняются только в промежуточный staging и не влияют на индекс без явной проверки и публикации администратором.
|
||||
@@ -84,6 +93,20 @@ docker compose up --build
|
||||
|
||||
Переменные и локальные значения по умолчанию перечислены в [.env.example](.env.example). Секретов в репозитории нет.
|
||||
|
||||
### Основные переменные окружения
|
||||
|
||||
| Группа | Переменные |
|
||||
|---|---|
|
||||
| База | `POSTGRES_DB`, `POSTGRES_USER`, `POSTGRES_PASSWORD`, `DATABASE_URL` |
|
||||
| Домены | `SITE_DOMAIN`, `FILES_DOMAIN`, `ACME_EMAIL` |
|
||||
| Администрирование | `ADMIN_TOKEN`, `ADMIN_BASIC_USER`, `ADMIN_BASIC_PASSWORD_HASH` |
|
||||
| Объекты | `MINIO_ROOT_USER`, `MINIO_ROOT_PASSWORD`, `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_BUCKET` |
|
||||
| Импорт | `OFFICIAL_RECORDS_URL`, `OFFICIAL_RECORDS_REGION`, `OFFICIAL_RECORDS_CATEGORY`, `OFFICIAL_IMPORT_REQUIRED`, `IMPORT_INTERVAL_SECONDS` |
|
||||
| Privacy/retention | `RATE_LIMIT_SECRET`, `RETENTION_*_DAYS` |
|
||||
| Эксплуатация | `BACKUP_ROOT`, `MONITOR_*`, `LOG_LEVEL` |
|
||||
|
||||
Полный production-шаблон с комментариями находится в [.env.production.example](.env.production.example). Перед запуском `deploy/preflight.sh` блокирует известные заглушки и ошибочное повторное использование MinIO credentials.
|
||||
|
||||
## Что реализовано
|
||||
|
||||
- FastAPI и SQLAlchemy 2;
|
||||
@@ -126,6 +149,8 @@ cd apps/web
|
||||
npm install
|
||||
npm run build
|
||||
npm audit --omit=dev
|
||||
npm run audit:axe
|
||||
npm run audit:lighthouse
|
||||
```
|
||||
|
||||
E2E после запуска Compose:
|
||||
@@ -173,6 +198,25 @@ curl -H "Authorization: Bearer change-me-in-production" \
|
||||
|
||||
Администратор может одобрить, отклонить или удалить сообщение. Удаление очищает ник, комментарий, исходную ссылку и объект скриншота, исключает запись из статистики, но сохраняет обезличенный факт действия в журнале аудита.
|
||||
|
||||
## Эксплуатация production
|
||||
|
||||
- первый запуск, обновление и preflight: [deploy/README.md](deploy/README.md);
|
||||
- backup/restore и учебное восстановление: [deploy/README.md](deploy/README.md#6-резервное-копирование-и-восстановление);
|
||||
- мониторинг, systemd timer и реакция на сбои: [docs/production-monitoring.md](docs/production-monitoring.md);
|
||||
- сроки хранения и очистка: [docs/data-retention.md](docs/data-retention.md);
|
||||
- лимиты и планы запросов: [docs/query-performance.md](docs/query-performance.md).
|
||||
|
||||
Production-логи структурированы в JSON и не содержат query string, IP, заголовков авторизации или пользовательских payload. Docker хранит не более пяти файлов по 10 МБ на сервис. Ежедневное обслуживание выполняет backup до retention и защищено от параллельного запуска.
|
||||
|
||||
## Известные ограничения альфы
|
||||
|
||||
- нет пользовательских аккаунтов, OCR, Telegram-бота и уведомлений о клёве;
|
||||
- community-источники требуют ручной проверки; неполные наблюдения не публикуются;
|
||||
- offset pagination рассчитана на пилотные объёмы, не на бесконечную ленту;
|
||||
- Lighthouse LCP локального прогона — 9,3 с; оптимизация изображения/CDN остаётся после размещения;
|
||||
- один сервер остаётся точкой отказа, поэтому обязательны внешний backup и мониторинг;
|
||||
- текущий публичный DNS/TLS не подтверждён, см. [статус развёртывания](docs/deployment-status.md).
|
||||
|
||||
## Исследовательский парсер официальных рекордов
|
||||
|
||||
```bash
|
||||
|
||||
Generated
+1163
-3
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,9 @@
|
||||
"check": "astro check",
|
||||
"test:unit": "node --test tests/unit/*.test.ts",
|
||||
"test:e2e": "playwright test",
|
||||
"test:bootstrap": "playwright test tests/production-bootstrap.spec.ts"
|
||||
"test:bootstrap": "playwright test tests/production-bootstrap.spec.ts",
|
||||
"audit:axe": "playwright test tests/accessibility.spec.ts",
|
||||
"audit:lighthouse": "lighthouse http://127.0.0.1:4321 --quiet --chrome-flags='--headless --no-sandbox' --only-categories=performance,accessibility --output=json --output-path=./lighthouse-report.json"
|
||||
},
|
||||
"dependencies": {
|
||||
"@astrojs/check": "^0.9.10",
|
||||
@@ -20,6 +22,8 @@
|
||||
"typescript": "^6.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^26.4.1"
|
||||
"@axe-core/playwright": "^4.13.0",
|
||||
"@types/node": "^26.4.1",
|
||||
"lighthouse": "^13.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,13 @@ const path = Astro.url.pathname;
|
||||
<html lang="ru">
|
||||
<head><meta charset="UTF-8" /><meta name="viewport" content="width=device-width" /><meta name="description" content="Свежие точки и статистика клёва Russian Fishing 4" /><link rel="icon" href="/favicon.svg" /><title>{title}</title></head>
|
||||
<body>
|
||||
<a class="skip-link" href="#main-content">Перейти к содержимому</a>
|
||||
<header class="topbar">
|
||||
<a href="/" class="brand" aria-label="Ни хвоста, ни чешуи"><span class="brand-mark" aria-hidden="true"><FishingIcon name="hook" size={24}/></span><span class="brand-name"><span>Ни хвоста,</span><strong>ни чешуи</strong></span></a>
|
||||
<nav aria-label="Разделы сайта"><a class:list={{active:path === "/"}} href="/"><FishingIcon name="float"/> <span>Сейчас клюёт</span></a><a class:list={{active:path.startsWith("/records")}} href="/records"><FishingIcon name="trophy"/> <span>Рекорды</span></a><a class:list={{active:path.startsWith("/report")}} href="/report"><FishingIcon name="plus"/> <span>Добавить улов</span></a></nav>
|
||||
<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("/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>
|
||||
<main><slot /></main>
|
||||
<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. Данные seed демонстрационные.</p><span>НХНЧ · 2026</span></footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -7,7 +7,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
<section class="moderation-app" data-api-url={apiUrl}>
|
||||
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button type="submit">Открыть очередь</button></form>
|
||||
<p class="privacy">Токен остаётся только в памяти страницы. Исходная ссылка и происхождение сохраняются при публикации.</p>
|
||||
<div class="notice error" data-admin-error hidden></div><div class="moderation-list" data-external-list aria-live="polite"></div>
|
||||
<div class="notice error" data-admin-error role="alert" hidden></div><div class="moderation-list" data-external-list aria-live="polite"></div>
|
||||
</section>
|
||||
<script>
|
||||
const root = document.querySelector<HTMLElement>(".moderation-app");
|
||||
|
||||
@@ -7,7 +7,7 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
<section class="moderation-app" data-api-url={apiUrl}>
|
||||
<form class="admin-login" autocomplete="off"><label>Административный токен<input name="token" type="password" required autocomplete="off" /></label><button type="submit">Открыть очередь</button></form>
|
||||
<p class="privacy">Токен хранится только в памяти страницы и не записывается в URL или localStorage.</p>
|
||||
<div class="notice error" data-admin-error hidden></div><div class="moderation-list" data-moderation-list aria-live="polite"></div>
|
||||
<div class="notice error" data-admin-error role="alert" hidden></div><div class="moderation-list" data-moderation-list aria-live="polite"></div>
|
||||
</section>
|
||||
<script>
|
||||
const root = document.querySelector<HTMLElement>(".moderation-app");
|
||||
@@ -29,7 +29,10 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000";
|
||||
login?.addEventListener("submit", async event => { event.preventDefault(); token = String(new FormData(login).get("token") || ""); try { await loadQueue(); login.hidden = true; } catch (cause) { fail(cause instanceof Error ? cause.message : "Ошибка загрузки."); } });
|
||||
list?.addEventListener("click", async event => {
|
||||
const button = (event.target as HTMLElement).closest<HTMLButtonElement>("button[data-decision],button[data-delete]"); const card = button?.closest<HTMLElement>("[data-report-id]"); if (!button || !card || !root) return;
|
||||
button.disabled = true; const reason = card.querySelector<HTMLTextAreaElement>("textarea")?.value || null;
|
||||
const reason = card.querySelector<HTMLTextAreaElement>("textarea")?.value.trim() || null;
|
||||
if (button.dataset.decision === "rejected" && !reason) { fail("Укажите причину отклонения."); card.querySelector("textarea")?.focus(); return; }
|
||||
if (button.hasAttribute("data-delete") && !window.confirm("Удалить и обезличить эту заявку? Действие нельзя отменить.")) return;
|
||||
button.disabled = true;
|
||||
try { const deleting = button.hasAttribute("data-delete"); const response = await fetch(`${root.dataset.apiUrl}/api/v1/admin/catch-reports/${card.dataset.reportId}`, {method:deleting ? "DELETE" : "PATCH",headers:{Authorization:`Bearer ${token}`,"Content-Type":"application/json"},body:deleting ? undefined : JSON.stringify({status:button.dataset.decision,reason})}); if (!response.ok) throw new Error("Не удалось сохранить решение."); card.remove(); if (list && !list.children.length) list.innerHTML = '<div class="state"><h2>Очередь пуста</h2><p>Все записи обработаны.</p></div>'; } catch (cause) { button.disabled = false; fail(cause instanceof Error ? cause.message : "Ошибка сохранения."); }
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
---
|
||||
import Layout from "../layouts/Layout.astro";
|
||||
import { api, kg, type ImportRun, type OfficialRecord } from "../lib/api";
|
||||
import { api, kg, type DictionaryItem, type ImportRun, type OfficialRecord } from "../lib/api";
|
||||
const params = Astro.url.searchParams;
|
||||
const fish = params.get("fish") ?? "";
|
||||
const waterbody = params.get("waterbody") ?? "";
|
||||
let records: OfficialRecord[] = [], runs: ImportRun[] = [], unavailable = false;
|
||||
try { [records, runs] = await Promise.all([api<OfficialRecord[]>(`/api/v1/records?fish=${fish}&waterbody=${waterbody}`), api<ImportRun[]>("/api/v1/imports?limit=1")]); } catch { unavailable = true; }
|
||||
let records: OfficialRecord[] = [], runs: ImportRun[] = [], fishes: DictionaryItem[] = [], waterbodies: DictionaryItem[] = [], unavailable = false;
|
||||
try { [records, runs, fishes, waterbodies] = await Promise.all([api<OfficialRecord[]>(`/api/v1/records?${new URLSearchParams({ fish, waterbody })}`), api<ImportRun[]>("/api/v1/imports?limit=1"), api<DictionaryItem[]>("/api/v1/fishes"), api<DictionaryItem[]>("/api/v1/waterbodies")]); } catch { unavailable = true; }
|
||||
const last = runs[0];
|
||||
---
|
||||
<Layout title="Официальные рекорды — RF4 Spotter">
|
||||
<section class="records-hero"><div><span class="eyebrow">Публичные данные RF4</span><h1>Официальные<br/><em>рекорды</em></h1></div><div class="source-status"><span class:list={["status-dot", last?.status]}></span><strong>{last ? `Импорт: ${last.status}` : "Импорт ещё не запускался"}</strong>{last?.finished_at && <small>{new Date(last.finished_at).toLocaleString("ru-RU")} · {last.rows_seen} строк</small>}</div></section>
|
||||
<form class="record-filters" method="get"><label>Slug рыбы<input name="fish" value={fish} placeholder="pike" /></label><label>Slug водоёма<input name="waterbody" value={waterbody} placeholder="vyunok" /></label><button>Фильтровать</button></form>
|
||||
<form class="record-filters" method="get"><label>Рыба<select name="fish"><option value="">Любая рыба</option>{fishes.map(item => <option value={item.slug} selected={fish === item.slug}>{item.name_ru}</option>)}</select></label><label>Водоём<select name="waterbody"><option value="">Все водоёмы</option>{waterbodies.map(item => <option value={item.slug} selected={waterbody === item.slug}>{item.name_ru}</option>)}</select></label><button>Фильтровать</button>{(fish || waterbody) && <a href="/records">Сбросить</a>}</form>
|
||||
<div class="section-heading content-grid"><div><span class="overline">Официальный источник</span><h2>Последние записи</h2></div><span class="result-count">{records.length} показано</span></div>
|
||||
{unavailable ? <div class="state content-grid"><h2>Источник временно недоступен</h2></div> : records.length ? <div class="record-table"><div class="record-row record-head"><span>Рыба</span><span>Вес</span><span>Водоём</span><span>Приманка</span><span>Игрок</span><span>Дата</span></div>{records.map(record => <article class="record-row"><strong>{record.fish}</strong><strong>{kg(record.weight_g)}</strong><span>{record.waterbody}</span><span>{record.bait ?? "—"}</span><span>{record.player_name ?? "—"}</span><time>{record.record_date ? new Date(record.record_date).toLocaleDateString("ru-RU") : "—"}</time></article>)}</div> : <div class="state content-grid"><h2>Рекорды ещё не импортированы</h2><p>Запустите <code>python -m app.cli import-records</code>. Пустой результат не подменяется демоданными.</p></div>}
|
||||
{unavailable ? <div class="state content-grid"><h2>Источник временно недоступен</h2></div> : records.length ? <div class="record-table"><div class="record-row record-head"><span>Рыба</span><span>Вес</span><span>Водоём</span><span>Приманка</span><span>Игрок</span><span>Дата</span></div>{records.map(record => <article class="record-row"><strong data-label="Рыба">{record.fish}</strong><strong data-label="Вес">{kg(record.weight_g)}</strong><span data-label="Водоём">{record.waterbody}</span><span data-label="Приманка">{record.bait ?? "—"}</span><span data-label="Игрок">{record.player_name ?? "—"}</span><time data-label="Дата">{record.record_date ? new Date(record.record_date).toLocaleDateString("ru-RU") : "—"}</time></article>)}</div> : <div class="state content-grid"><h2>Рекорды ещё не импортированы</h2><p>Для выбранных условий записей пока нет.</p></div>}
|
||||
<p class="official-note">Источник: <a href="https://rf4game.de/records/region/RU/" rel="noreferrer">официальный сайт Russian Fishing 4</a>. Координаты в официальных таблицах отсутствуют.</p>
|
||||
</Layout>
|
||||
|
||||
@@ -7,14 +7,20 @@ const state = Astro.url.searchParams.get("state");
|
||||
const reportId = Astro.url.searchParams.get("report_id");
|
||||
---
|
||||
<Layout title="Добавить улов — RF4 Spotter">
|
||||
<section class="form-hero"><div><span class="eyebrow"><b>+1</b> Помочь сообществу</span><h1>Добавить<br/><em>свой улов</em></h1></div><p>Полминуты — и рабочая точка появится в общей статистике после проверки модератором.</p></section>
|
||||
<section class="form-hero"><div><span class="eyebrow"><b>+1</b> Помочь сообществу</span><h1>Добавить<br/><em>свой улов</em></h1></div><p>Около минуты — и рабочая точка появится в общей статистике после проверки модератором.</p></section>
|
||||
{state === "sent" && <div class="notice success">Улов отправлен на модерацию. Спасибо!</div>}
|
||||
{state === "screenshot_sent" && <div class="notice success">Скриншот добавлен к ранее созданной заявке.</div>}
|
||||
{state === "create_error" && <div class="notice error">Заявка не создана. Проверьте поля и повторите отправку.</div>}
|
||||
{state === "create_error" && <div class="notice error" id="form-error" tabindex="-1"><strong>Заявка не создана.</strong> Проверьте поля; введённые значения сохранены.</div>}
|
||||
{state === "screenshot_error" && <div class="notice warning"><strong>Заявка сохранена без скриншота.</strong> Изображение не загрузилось; можно повторить отдельно, не отправляя улов заново.</div>}
|
||||
{state === "screenshot_error" && reportId && <form class="screenshot-retry" method="post" action="/api/report-screenshot" enctype="multipart/form-data"><input type="hidden" name="report_id" value={reportId} /><label>Повторная загрузка скриншота<input name="screenshot" type="file" accept="image/jpeg,image/png,image/webp" required /></label><button type="submit">Загрузить скриншот</button></form>}
|
||||
{unavailable ? <div class="state"><h2>Форма временно недоступна</h2></div> : <form class="report-form" method="post" action="/api/report" enctype="multipart/form-data">
|
||||
<div class="form-grid"><label>Рыба *<select name="fish_slug" required>{fishes.map(x => <option value={x.slug}>{x.name_ru}</option>)}</select></label><label>Водоём *<select name="waterbody_slug" required>{waterbodies.map(x => <option value={x.slug}>{x.name_ru}</option>)}</select></label><label>Координата X *<input name="x" type="number" min="-10000" max="10000" required /></label><label>Координата Y *<input name="y" type="number" min="-10000" max="10000" required /></label><label>Вес, граммы *<input name="weight_g" type="number" min="1" max="3000000" required /></label><label>Приманка<input name="bait_name" maxlength="200" /></label><label>Способ ловли<select name="fishing_method"><option value="">Не указан</option><option value="spinning">Спиннинг</option><option value="bottom">Донная</option><option value="float">Поплавочная</option></select></label><label>Проводка<input name="retrieve_method" maxlength="100" /></label><label>Скорость проводки<input name="retrieve_speed" type="number" min="0" max="100" /></label><label>Ник игрока<input name="player_name" maxlength="100" /></label></div>
|
||||
<label class="wide">Комментарий<textarea name="comment" maxlength="1000" rows="4"></textarea></label><label class="wide">Скриншот, JPEG/PNG/WebP до 8 МБ<input name="screenshot" type="file" accept="image/jpeg,image/png,image/webp" /></label><label class="honeypot" aria-hidden="true">Сайт<input name="website" tabindex="-1" autocomplete="off" /></label><p class="privacy">Ник и скриншот необязательны. Из изображения удаляются EXIF и прочие метаданные.</p><button type="submit">Отправить на проверку</button>
|
||||
<fieldset><legend>Главное <span>обязательно</span></legend><div class="form-grid"><label>Рыба *<select name="fish_slug" required>{fishes.map(x => <option value={x.slug}>{x.name_ru}</option>)}</select></label><label>Водоём *<select name="waterbody_slug" required>{waterbodies.map(x => <option value={x.slug}>{x.name_ru}</option>)}</select></label><label>Координата X *<input name="x" type="number" min="-10000" max="10000" placeholder="Например, 72" required /></label><label>Координата Y *<input name="y" type="number" min="-10000" max="10000" placeholder="Например, 84" required /></label><label>Вес, граммы *<input name="weight_g" type="number" min="1" max="3000000" placeholder="1250" required /></label><label>Приманка<input name="bait_name" maxlength="200" placeholder="Название в игре" /></label></div><p class="field-help">Координаты — два целых числа с карты. Вес: 1,25 кг = 1250 г.</p></fieldset>
|
||||
<details class="optional-fields"><summary>Дополнительные сведения <span>необязательно</span></summary><div class="form-grid"><label>Способ ловли<select name="fishing_method"><option value="">Не указан</option><option value="spinning">Спиннинг</option><option value="bottom">Донная</option><option value="float">Поплавочная</option></select></label><label>Проводка<input name="retrieve_method" maxlength="100" /></label><label>Скорость проводки<input name="retrieve_speed" type="number" min="0" max="100" /></label><label>Ник игрока<input name="player_name" maxlength="100" /></label></div><label class="wide">Комментарий<textarea name="comment" maxlength="1000" rows="4"></textarea></label><label class="wide">Скриншот, JPEG/PNG/WebP до 8 МБ<input name="screenshot" type="file" accept="image/jpeg,image/png,image/webp" /></label></details><label class="honeypot" aria-hidden="true">Сайт<input name="website" tabindex="-1" autocomplete="off" /></label><p class="privacy">Ник и скриншот необязательны. Из изображения удаляются EXIF и прочие метаданные.</p><button type="submit">Отправить на проверку</button>
|
||||
</form>}
|
||||
<script is:inline define:vars={{ state }}>
|
||||
const form = document.querySelector(".report-form"); const key = "rf4-report-draft";
|
||||
if (form && state === "create_error") { try { const draft = JSON.parse(sessionStorage.getItem(key) || "{}"); for (const [name, value] of Object.entries(draft)) { const field = form.elements.namedItem(name); if (field && "value" in field) field.value = value; } } catch {} document.querySelector("#form-error")?.focus(); }
|
||||
if (state === "sent") sessionStorage.removeItem(key);
|
||||
form?.addEventListener("submit", () => { const draft = {}; for (const [name, value] of new FormData(form)) if (typeof value === "string" && name !== "website") draft[name] = value; sessionStorage.setItem(key, JSON.stringify(draft)); });
|
||||
</script>
|
||||
</Layout>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
:root{--paper:#f2f5ee;--deep:#082226;--teal:#12383b;--lime:#c9f45b;--ink:#092226;--muted:#647472;--line:#cbd6ce;--white:#fff;--orange:#ffb65c;font-family:Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;color:var(--ink);background:var(--paper)}
|
||||
*{box-sizing:border-box}[hidden]{display:none!important}html{scroll-behavior:smooth}body{margin:0;min-width:320px;background:var(--paper);color:var(--ink)}a{color:inherit}button,input,select,textarea{font:inherit}button{cursor:pointer}.content-grid{width:min(1360px,calc(100% - 64px));margin-inline:auto}main{min-height:calc(100vh - 204px)}
|
||||
.skip-link{position:fixed;z-index:100;left:12px;top:12px;padding:12px 16px;background:var(--lime);color:var(--deep);font-weight:750;transform:translateY(-150%)}.skip-link:focus{transform:none}:focus-visible{outline:3px solid #7da529;outline-offset:3px}.topbar nav a[aria-current="page"]{font-weight:750}.notice:focus{outline:3px solid #9d3529;outline-offset:3px}
|
||||
.topbar{height:86px;width:min(1480px,calc(100% - 48px));margin:auto;display:grid;grid-template-columns:1fr auto 1fr;align-items:center;gap:28px}.brand{display:flex;align-items:center;gap:12px;text-decoration:none;min-width:max-content}.brand-mark{width:42px;height:42px;display:grid;place-items:center;border:1px solid #9eb0a7;border-radius:50%;background:var(--deep);color:var(--lime);font-size:28px}.brand-name{display:flex;flex-direction:column;font:17px/.95 Georgia,serif}.brand-name strong{font-size:20px;font-style:italic}.topbar nav{height:100%;display:flex;gap:23px;align-items:center}.topbar nav a{height:100%;display:flex;align-items:center;gap:7px;padding:0 8px;text-decoration:none;color:#526461;font-size:14px;border-bottom:3px solid transparent}.topbar nav a.active{color:var(--deep);border-color:var(--deep)}.live-badge{justify-self:end;display:flex;align-items:center;gap:9px;color:#60716e;font-size:13px}.live-badge>span{width:8px;height:8px;border-radius:50%;background:#83b83a;box-shadow:0 0 0 5px #83b83a20}
|
||||
.eyebrow,.overline{text-transform:uppercase;letter-spacing:.14em;font-size:12px;font-weight:750;color:#657673}.eyebrow{display:flex;align-items:center;gap:10px}.eyebrow b{display:grid;place-items:center;min-width:41px;height:25px;padding:0 8px;border:1px solid #9aaa9f;border-radius:20px;color:var(--deep);letter-spacing:.05em}.intro{padding:74px 0 42px;display:grid;grid-template-columns:.92fr 1.08fr;align-items:end;gap:70px}.intro h1,.records-hero h1,.form-hero h1,.spot-hero h1{margin:18px 0 22px;font:400 clamp(58px,6.3vw,102px)/.87 Georgia,"Times New Roman",serif;letter-spacing:-.065em}.intro h1 em,.records-hero h1 em,.form-hero h1 em{color:#497074;font-weight:400}.intro-copy>p{font-size:18px;line-height:1.55;color:#586a68;max-width:560px}.lake-card{position:relative;border-radius:18px;overflow:hidden;height:326px;box-shadow:0 24px 70px #173c3f24}.lake-card img{width:100%;height:100%;object-fit:cover;display:block}.lake-card:after{content:"";position:absolute;inset:0;background:linear-gradient(180deg,transparent 35%,#041c20d9)}.lake-overlay{position:absolute;z-index:2;inset:auto 24px 22px 26px;display:flex;justify-content:space-between;align-items:end;color:#fff}.lake-overlay .overline{color:#d2dedb;display:block;margin-bottom:7px}.lake-overlay strong{font:400 21px Georgia,serif}.pulse-orb{width:45px;height:45px;border:1px solid #ffffff7d;border-radius:50%;display:grid;place-items:center}.pulse-orb span{width:9px;height:9px;border-radius:50%;background:var(--lime);box-shadow:0 0 0 8px #c9f45b25}
|
||||
.filters-wrap{background:var(--deep);padding:24px 0;position:sticky;top:0;z-index:20;box-shadow:0 10px 35px #08222620}.filters{display:grid;grid-template-columns:repeat(4,1fr) auto;gap:12px;align-items:end}.filters label,.report-form label,.record-filters label{color:#9fb0ad;font-size:12px;text-transform:uppercase;letter-spacing:.1em;font-weight:700}.filters select{display:block;width:100%;height:48px;margin-top:7px;padding:0 13px;border:1px solid #ffffff29;border-radius:10px;background:#ffffff0c;color:#f5f8f3}.filters option{color:var(--ink)}.filters button,.report-form button,.record-filters button{height:48px;padding:0 24px;border:0;border-radius:10px;background:var(--lime);color:var(--deep);font-weight:750}.filter-advanced{display:contents}.filter-advanced summary{display:none}.advanced-fields{display:contents!important}.active-filters{min-height:56px;display:flex;align-items:center;gap:8px;padding-top:10px}.active-filters span{padding:6px 10px;border:1px solid #c8d3cb;border-radius:20px;background:#f8faf5;color:#526662;font-size:12px}.active-filters a{margin-left:auto;color:#4d625e;font-size:13px;text-underline-offset:3px}
|
||||
@@ -21,3 +22,8 @@ footer{min-height:118px;background:var(--deep);color:#dbe4df;padding:28px max(32
|
||||
.float-gauge{width:122px;height:142px;position:relative;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;padding-bottom:8px;isolation:isolate}.float-gauge:before,.float-gauge:after{content:"";position:absolute;left:3px;right:3px;border:1px solid #ffffff1d;border-radius:50%}.float-gauge:before{height:28px;bottom:25px;box-shadow:0 0 0 10px #ffffff08}.float-gauge:after{height:15px;bottom:32px}.float-gauge__line{position:absolute;left:50%;top:0;width:1px;height:105px;background:linear-gradient(#ffffff55,#ffffff12);z-index:-1}.float-gauge__water{position:absolute;left:11px;right:11px;bottom:39px;height:calc(var(--level)*.62);max-height:62px;background:linear-gradient(180deg,#c9f45b08,#c9f45b36);border-top:1px solid var(--lime);clip-path:polygon(0 6px,18% 0,36% 6px,54% 0,72% 6px,90% 0,100% 4px,100% 100%,0 100%)}.float-gauge__bob{position:absolute;left:50%;bottom:calc(39px + var(--level)*.62);width:18px;height:54px;transform:translate(-50%,50%);border:1px solid #e6f8ad;border-radius:50% 50% 42% 42%;background:linear-gradient(180deg,var(--lime) 0 35%,#f4f7ed 36% 58%,#e47e3b 59%);box-shadow:0 5px 18px #c9f45b38}.float-gauge__bob i{position:absolute;left:50%;top:-24px;width:1px;height:25px;background:#dfe9dc}.float-gauge strong{position:relative;z-index:2;font:400 31px/1 Georgia,serif;text-shadow:0 2px 8px var(--deep)}.float-gauge small{position:relative;z-index:2;color:#a9b8b5;font-size:9px;text-transform:uppercase}.best-lure>div{grid-template-columns:25px 1fr auto}
|
||||
@media(max-width:720px){.float-gauge{width:100px}}
|
||||
@media(prefers-reduced-motion:no-preference){.pulse-orb span{animation:ripple-pulse 2.4s ease-out infinite}.float-gauge__bob{animation:bob 2.8s ease-in-out infinite}}@keyframes ripple-pulse{50%{box-shadow:0 0 0 15px #c9f45b08}}@keyframes bob{50%{translate:0 -3px}}
|
||||
.report-form fieldset{border:0;padding:0;margin:0}.report-form legend{width:100%;margin-bottom:18px;font:400 24px Georgia,serif}.report-form legend span,.optional-fields summary span{float:right;font:11px Inter,sans-serif;text-transform:uppercase;letter-spacing:.08em;color:#7c8c89}.field-help{color:#657572;font-size:12px}.optional-fields{margin:26px 0;border-block:1px solid #d6dfd7;padding:18px 0}.optional-fields summary{cursor:pointer;font:400 20px Georgia,serif}.optional-fields[open] summary{margin-bottom:20px}.report-form input:user-invalid,.report-form select:user-invalid{border-color:#b84c3c}
|
||||
.record-filters select{display:block;width:100%;height:46px;margin-top:7px;padding:0 13px;border:1px solid #ffffff29;border-radius:9px;background:#ffffff0c;color:#fff}.record-filters option{color:var(--ink)}.record-filters>a{align-self:end;height:48px;display:grid;place-items:center;padding:0 12px;color:#dce5e2;font-size:13px}
|
||||
@media(max-width:720px){.record-row:not(.record-head)>*{display:grid;grid-template-columns:88px 1fr;text-align:left!important;gap:8px}.record-row:not(.record-head)>*:before{content:attr(data-label);font:700 9px Inter,sans-serif;text-transform:uppercase;letter-spacing:.08em;color:#82908e}.record-row{grid-template-columns:1fr}.record-filters>a{height:auto;padding:8px}}
|
||||
/* WCAG AA secondary text on light surfaces */
|
||||
.eyebrow,.overline,.result-count,.spot-rank,.spot-topline,.spot-meta,.bait-line div span,.spot-stats span,.data-note,.state,.principles p,.principles article>span,.official-note,.privacy,.field-help,.report-form legend span,.optional-fields summary span,.record-row>span,.record-row>time{color:#4d605d}
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
for (const path of ["/", "/records", "/report"]) {
|
||||
test(`${path} has no serious accessibility violations`, async ({ page }) => {
|
||||
await page.goto(path);
|
||||
const results = await new AxeBuilder({ page }).withTags(["wcag2a", "wcag2aa", "wcag21aa"]).analyze();
|
||||
expect(results.violations.filter(item => ["critical", "serious"].includes(item.impact ?? ""))).toEqual([]);
|
||||
});
|
||||
}
|
||||
@@ -25,6 +25,12 @@ docker run --rm caddy:2.10.2-alpine caddy hash-password --plaintext 'ОТДЕЛ
|
||||
|
||||
## 3. Проверка и первый запуск
|
||||
|
||||
Перед первым запуском проверьте секреты и итоговую Compose-конфигурацию, не выводя значения в лог:
|
||||
|
||||
```bash
|
||||
./deploy/preflight.sh
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env.production -f compose.production.yaml config --quiet
|
||||
docker compose --env-file .env.production -f compose.production.yaml build
|
||||
@@ -34,6 +40,12 @@ curl -fsS https://rf4spotter.ru/health
|
||||
curl -fsS https://rf4spotter.ru/ready
|
||||
```
|
||||
|
||||
После обновления DNS и получения сертификатов выполните внешний этап той же проверки:
|
||||
|
||||
```bash
|
||||
./deploy/preflight.sh --online
|
||||
```
|
||||
|
||||
До запуска на сервере можно воспроизвести полный production bootstrap на пустых изолированных volumes. Скрипт собирает образы, применяет миграции, проверяет отсутствие демо-уловов, readiness и отправку заявки:
|
||||
|
||||
```bash
|
||||
|
||||
Executable
+38
@@ -0,0 +1,38 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
repo=$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)
|
||||
cd "$repo"
|
||||
env_file=${COMPOSE_ENV_FILE:-.env.production}
|
||||
online=${1:-}
|
||||
|
||||
fail() { printf 'FAIL %s\n' "$1" >&2; exit 1; }
|
||||
read_env() { sed -n "s/^$1=//p" "$env_file" | tail -n 1 | sed "s/^'//;s/'$//"; }
|
||||
|
||||
[ -r "$env_file" ] || fail "$env_file is not readable"
|
||||
if grep -Eq '=(replace-|change-me|example|changeme)' "$env_file"; then fail "placeholder secret remains in $env_file"; fi
|
||||
|
||||
site=$(read_env SITE_DOMAIN)
|
||||
files=$(read_env FILES_DOMAIN)
|
||||
root_user=$(read_env MINIO_ROOT_USER)
|
||||
root_password=$(read_env MINIO_ROOT_PASSWORD)
|
||||
app_user=$(read_env S3_ACCESS_KEY)
|
||||
app_password=$(read_env S3_SECRET_KEY)
|
||||
[ -n "$site" ] && [ -n "$files" ] || fail "SITE_DOMAIN/FILES_DOMAIN missing"
|
||||
[ "$root_user" != "$app_user" ] || fail "MinIO root and app users must differ"
|
||||
[ "$root_password" != "$app_password" ] || fail "MinIO root and app passwords must differ"
|
||||
|
||||
docker compose --env-file "$env_file" -f compose.production.yaml config --quiet
|
||||
printf 'OK environment and Compose configuration\n'
|
||||
|
||||
[ "$online" = "--online" ] || exit 0
|
||||
site_ip=$(getent ahostsv4 "$site" | awk 'NR==1 {print $1}')
|
||||
files_ip=$(getent ahostsv4 "$files" | awk 'NR==1 {print $1}')
|
||||
[ -n "$site_ip" ] && [ "$site_ip" = "$files_ip" ] || fail "DNS A records are missing or point to different hosts"
|
||||
for domain in "$site" "$files"; do
|
||||
openssl s_client -servername "$domain" -connect "$domain:443" </dev/null 2>/dev/null | openssl x509 -noout -checkhost "$domain" >/dev/null || fail "TLS hostname mismatch: $domain"
|
||||
done
|
||||
curl -fsS --max-time 15 "https://$site/health" >/dev/null || fail "public health failed"
|
||||
curl -fsS --max-time 15 "https://$site/ready" | grep -Eq '"status"[[:space:]]*:[[:space:]]*"ready"' || fail "public readiness failed"
|
||||
curl -fsS --max-time 15 "https://$files/minio/health/live" >/dev/null || fail "public storage health failed"
|
||||
printf 'OK DNS, TLS and public health (%s)\n' "$site_ip"
|
||||
+9
-12
@@ -69,19 +69,19 @@
|
||||
- [x] Проверить авторизацию повторной загрузки скриншота: используется отдельный одноразовый случайный токен, в БД хранится только SHA-256, UUID заявки недостаточно.
|
||||
- [x] Определить сроки хранения ников, исходных payload, staging-наблюдений, moderation events и submission attempts; добавлены настраиваемая dry-run-first очистка, тест и `docs/data-retention.md`.
|
||||
- [x] Добавить резервное копирование и документированное восстановление PostgreSQL и MinIO: консистентные `pg_dump` и MinIO API mirror, контрольные суммы, runbook и успешный изолированный drill с намеренным удалением данных (6 сентября 2026).
|
||||
- [ ] Проверить доступность интерфейса: клавиатура, focus states, контраст, подписи полей и семантика таблиц/карточек.
|
||||
- [ ] Провести Lighthouse-проверку основных страниц и устранить критические проблемы производительности.
|
||||
- [x] Проверить доступность публичного интерфейса: focus states, skip-link, контраст, подписи полей и axe на главной/рекордах/форме без critical/serious нарушений.
|
||||
- [x] Провести Lighthouse-проверку главной: accessibility 100, performance 75, FCP 1,2 с, TBT 0 мс; LCP 9,3 с оставлен наблюдаемым бюджетом оптимизации после размещения CDN.
|
||||
- [x] Провести UI/UX-аудит desktop/mobile и сформировать приоритетный план (`docs/UI_UX_AUDIT.md`).
|
||||
- [x] Исправить единую шкалу активности и русские числительные во всех публичных представлениях (пакет A UI/UX-аудита; unit-тест границ и E2E согласованности).
|
||||
- [x] Дополнить визуальный язык авторскими рыболовными SVG-иконками и шкалой активности в форме поплавка; анимации учитывают `prefers-reduced-motion`.
|
||||
- [x] Сократить мобильный путь до результатов и улучшить фильтры: компактный hero, основные фильтры перед глазами, раскрываемые период/сортировка, строка условий и сброс (пакет B UI/UX-аудита).
|
||||
- [ ] Упростить форму улова, добавить помощь форматов и сохранение значений при ошибке (пакет C UI/UX-аудита).
|
||||
- [ ] Заменить slug-фильтры и улучшить мобильное представление рекордов/detail (пакет D UI/UX-аудита).
|
||||
- [ ] Выполнить accessibility/admin safety пакет с axe/Lighthouse (пакет E UI/UX-аудита).
|
||||
- [x] Упростить форму улова, добавить помощь форматов, валидацию и сохранение значений при ошибке (пакет C UI/UX-аудита).
|
||||
- [x] Заменить slug-фильтры справочниками, добавить сброс и самодостаточные мобильные строки рекордов (пакет D UI/UX-аудита; detail уже адаптивен).
|
||||
- [x] Завершить accessibility/admin safety пакет с воспроизводимыми axe/Lighthouse-командами (пакет E).
|
||||
- [ ] Добавить smoke-проверку административной очереди внешних источников на desktop/mobile без публикации реальных записей.
|
||||
- [ ] Обновить README: архитектура, все переменные окружения, импорт, модерация, backup/restore, эксплуатация логов и известные ограничения.
|
||||
- [x] Обновить README: актуальный статус, архитектура, группы переменных окружения, импорт, модерация, backup/restore, эксплуатация логов и известные ограничения.
|
||||
- [ ] Выбрать лицензию кода и политику использования данных.
|
||||
- [ ] Завершить production-профиль для `rf4spotter.ru`: приложение готово; внешняя проверка 6 сентября выявила неверный сертификат `*.timeweb.ru` на корневом домене и отсутствие DNS для `files.rf4spotter.ru`; требуются IP целевого сервера, DNS, запуск и канал уведомлений (`docs/deployment-status.md`).
|
||||
- [ ] Завершить production-профиль для `rf4spotter.ru`: приложение и `deploy/preflight.sh` готовы; внешняя проверка 7 сентября не установила соединение с обоими доменами, требуются IP целевого сервера, DNS, запуск и канал уведомлений (`docs/deployment-status.md`).
|
||||
- [ ] Заменить демонстрационные секреты и определить целевое размещение перед внешней публикацией.
|
||||
|
||||
## Источники данных и согласование
|
||||
@@ -119,11 +119,8 @@
|
||||
|
||||
Технический production-контур, health/readiness, backup/restore и безопасные логи готовы. Следующие пункты выполняются строго по одному:
|
||||
|
||||
1. UI/UX-пакет C: упростить форму улова и сохранить введённые значения при ошибке;
|
||||
2. UI/UX-пакет D: фильтры и мобильное представление рекордов/detail;
|
||||
3. accessibility/admin safety и Lighthouse;
|
||||
4. мониторинг, DNS/TLS и проверка production-профиля на целевом сервере;
|
||||
5. финальное обновление README, лицензия кода и политика данных.
|
||||
1. выбрать лицензию кода и оформить политику использования данных;
|
||||
2. мониторинг, DNS/TLS и проверка production-профиля на целевом сервере.
|
||||
|
||||
После каждого пункта необходимо:
|
||||
|
||||
|
||||
+11
-11
@@ -61,18 +61,18 @@
|
||||
|
||||
### Пакет C — форма улова
|
||||
|
||||
- [ ] Разделить форму на обязательную основу и раскрываемые дополнительные сведения.
|
||||
- [ ] Добавить примеры координат и помощь с единицами веса.
|
||||
- [ ] Показывать ошибки рядом с конкретным полем и общий summary с переводом фокуса.
|
||||
- [ ] Сохранять введённые значения после ошибки создания и отдельно обрабатывать повтор скриншота.
|
||||
- [ ] Привести обещание времени заполнения в соответствие с измеренным сценарием.
|
||||
- [x] Разделить форму на обязательную основу и раскрываемые дополнительные сведения.
|
||||
- [x] Добавить примеры координат и помощь с единицами веса.
|
||||
- [x] Показывать нативные ошибки рядом с полем и общий summary с переводом фокуса.
|
||||
- [x] Сохранять значения в `sessionStorage` после ошибки; повтор скриншота обрабатывать отдельно.
|
||||
- [x] Привести обещание времени заполнения к реалистичному «около минуты».
|
||||
|
||||
Критерий: новый пользователь без документации понимает формат координат/веса, ошибка не уничтожает введённые данные, обязательные поля визуально очевидны.
|
||||
|
||||
### Пакет D — рекорды и detail точки
|
||||
|
||||
- [ ] Заменить slug-input фильтрами из справочников и добавить сброс.
|
||||
- [ ] Сделать мобильную выдачу рекордов самодостаточной: подписи значений или доступная таблица.
|
||||
- [x] Заменить slug-input фильтрами из справочников и добавить сброс.
|
||||
- [x] Сделать мобильную выдачу рекордов самодостаточной с подписями значений.
|
||||
- [ ] Убрать CLI-команду из публичного empty state.
|
||||
- [ ] Добавить к уловам detail-страницы дату/свежесть и происхождение, если API их отдаёт.
|
||||
- [ ] Устранить дублирование карточки и sidebar на малой выборке.
|
||||
@@ -81,11 +81,11 @@
|
||||
|
||||
### Пакет E — accessibility и административная безопасность
|
||||
|
||||
- [ ] Добавить глобальные `:focus-visible`, skip-link и заметное состояние текущей страницы.
|
||||
- [x] Добавить глобальные `:focus-visible`, skip-link и заметное состояние текущей страницы.
|
||||
- [ ] Пройти публичный и административный сценарии только клавиатурой.
|
||||
- [ ] Проверить контраст WCAG AA, доступные имена статусов и `aria-live` ошибок/успеха.
|
||||
- [ ] Добавить подтверждение удаления и обязательную причину отклонения.
|
||||
- [ ] Запустить axe/Lighthouse и сохранить измеримые результаты.
|
||||
- [x] Проверить контраст WCAG AA и доступные имена статусов; динамические ошибки и списки размечены `role=alert`/`aria-live`.
|
||||
- [x] Добавить подтверждение удаления и обязательную причину отклонения.
|
||||
- [x] Запустить axe/Lighthouse: 3 публичные страницы без critical/serious axe; главная Lighthouse accessibility 100, performance 75, FCP 1,2 с, LCP 9,3 с, TBT 0 мс (локальный production build, 7 сентября 2026).
|
||||
|
||||
Критерий: нет critical/serious нарушений axe; все действия доступны с клавиатуры; destructive action невозможно выполнить случайным одиночным нажатием.
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Статус развёртывания rf4spotter.ru
|
||||
|
||||
Последняя внешняя read-only проверка: 6 сентября 2026 года.
|
||||
Последняя внешняя read-only проверка: 7 сентября 2026 года. Web-клиент не смог установить соединение ни с корневым доменом, ни с `files`; прежние DNS/TLS-блокеры считаются незакрытыми до успешного `deploy/preflight.sh --online` на целевом сервере.
|
||||
|
||||
## Что видно публично сейчас
|
||||
|
||||
@@ -25,5 +25,6 @@
|
||||
- [ ] Убедиться, что Caddy получил сертификаты с SAN `rf4spotter.ru` и `files.rf4spotter.ru`.
|
||||
- [ ] Выполнить `curl` для `/`, `/health`, `/ready` и MinIO health через оба домена.
|
||||
- [ ] Создать первую внешнюю резервную копию, запустить `deploy/monitor.sh` и подключить уведомления.
|
||||
- [x] Добавить единый preflight без вывода секретов: проверка env/Compose до запуска и DNS/TLS/health после запуска (`deploy/preflight.sh`).
|
||||
|
||||
Не следует отключать TLS verification или сохранять текущий чужой сертификат в исключения: после правильных DNS и доступных портов Caddy должен получить корректный сертификат автоматически.
|
||||
|
||||
Reference in New Issue
Block a user