From 9681bdbd36ff75d70a902208602542b5ad33f7bc Mon Sep 17 00:00:00 2001 From: IK Date: Sat, 12 Sep 2026 21:51:09 +0700 Subject: [PATCH] feat: show moderation provenance --- README.md | 2 ++ apps/web/src/pages/admin/external-sources.astro | 13 +++++++++++++ apps/web/src/styles/global.css | 1 + docs/ROADMAP.md | 2 +- 4 files changed, 17 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 4e69b92..65b484d 100644 --- a/README.md +++ b/README.md @@ -252,6 +252,8 @@ curl -H "Authorization: Bearer change-me-in-production" \ Административный API внешней очереди возвращает безопасный provenance: время первого и последнего обнаружения, время проверки, отсутствующие поля и только разрешённые скалярные поля исходной записи. Неизвестные ключи и вложенные служебные структуры в ответ не попадают. +В карточке внешнего наблюдения provenance доступен в отдельном раскрываемом блоке: временная линия, отсутствующие и исходные разрешённые поля видны до сопоставления и публикации. + ## Эксплуатация production - первый запуск, обновление и preflight: [deploy/README.md](deploy/README.md); diff --git a/apps/web/src/pages/admin/external-sources.astro b/apps/web/src/pages/admin/external-sources.astro index 9df05aa..5c9ca86 100644 --- a/apps/web/src/pages/admin/external-sources.astro +++ b/apps/web/src/pages/admin/external-sources.astro @@ -64,6 +64,19 @@ const apiUrl = import.meta.env.PUBLIC_API_URL || "http://localhost:8000"; if (pageNumber) pageNumber.textContent = `Страница ${offset / 50 + 1}`; if (!pending.length) { list.innerHTML = '

Очередь пуста

Все внешние записи обработаны.

'; return; } list.innerHTML = pending.map((row: Record) => { const complete = row.x != null && row.y != null && row.weight_g != null; const sourceUrl = safeHttpUrl(row.source_url); return `
${esc(row.source_system)} · ${esc(row.status)}

${esc(row.fish_name)}

${esc(row.waterbody_name)}

Координаты
${row.x == null || row.y == null ? "нет" : `${esc(row.x)}:${esc(row.y)}`}
Вес
${row.weight_g == null ? "нет" : `${esc(row.weight_g)} г`}
ID источника
${esc(row.source_external_id)}
Состояние
${complete ? "полная запись" : "неполная"}
${sourceUrl ? `Открыть первоисточник` : 'Ссылка источника имеет небезопасный формат'}

`; }).join(""); + const payloadLabels: Record = {bait:"Приманка",fishing_method:"Метод ловли",rig_type:"Оснастка",retrieve_method:"Проводка",retrieve_speed:"Скорость проводки",player_name:"Игрок",published_at:"Опубликовано источником",region:"Регион",category:"Категория"}; + const missingLabels: Record = {coordinates:"координаты",weight_g:"вес"}; + pending.forEach((row: Record, index: number) => { + const card = list.children[index] as HTMLElement | undefined; + const summary = card?.querySelector(".moderation-summary"); + if (!summary) return; + const payload = Object.entries((row.source_payload as Record) ?? {}); + const missing = ((row.missing_fields as string[]) ?? []).map(key => missingLabels[key] ?? key); + const details = document.createElement("details"); + details.className = "moderation-provenance"; + details.innerHTML = `Происхождение и исходные поля
Впервые замечено
${esc(new Date(String(row.first_seen_at)).toLocaleString("ru-RU"))}
Последний раз
${esc(new Date(String(row.last_seen_at)).toLocaleString("ru-RU"))}
Проверено
${row.reviewed_at ? esc(new Date(String(row.reviewed_at)).toLocaleString("ru-RU")) : "ещё нет"}
Не хватает
${missing.length ? esc(missing.join(", ")) : "ничего"}
${payload.map(([key,value]) => `
${esc(payloadLabels[key] ?? key)}
${esc(value)}
`).join("")}

Показаны только разрешённые поля, сохранённые парсером. Перед публикацией сверьте их с первоисточником.

`; + summary.append(details); + }); } async function changePage(delta: number) { const oldOffset = offset; diff --git a/apps/web/src/styles/global.css b/apps/web/src/styles/global.css index 82c8f65..5058db1 100644 --- a/apps/web/src/styles/global.css +++ b/apps/web/src/styles/global.css @@ -16,6 +16,7 @@ .admin-session-bar{display:flex;align-items:center;justify-content:space-between;gap:16px;margin:0 0 18px;padding:12px 14px;border:1px solid var(--line);border-radius:10px;background:var(--surface);color:var(--muted);font-size:13px}.admin-session-bar[hidden]{display:none}.admin-session-bar button{min-height:38px} .admin-queue-filters{display:grid;grid-template-columns:1.3fr repeat(3,1fr) auto;gap:10px;align-items:end;margin:0 0 18px;padding:16px;border-radius:12px;background:var(--deep)}.admin-queue-filters[hidden]{display:none}.admin-queue-filters label{color:#b8c5c2;font-size:11px;text-transform:uppercase;letter-spacing:.08em}.admin-queue-filters input,.admin-queue-filters select{display:block;width:100%;height:42px;margin-top:6px;padding:0 10px;border:1px solid #ffffff29;border-radius:8px;background:#ffffff0c;color:#fff}.admin-queue-filters option{color:var(--ink)}.admin-queue-filters button{height:42px} .admin-shortcuts{margin:14px 0;color:var(--text-muted);font-size:12px}.admin-shortcuts kbd{padding:2px 6px;border:1px solid var(--line);border-bottom-width:2px;border-radius:5px;background:var(--surface);font:700 11px Inter,sans-serif}.moderation-card:focus-within{outline:2px solid var(--focus);outline-offset:3px} +.moderation-provenance{margin-top:18px;border-top:1px solid var(--border-soft);padding-top:14px}.moderation-provenance summary{cursor:pointer;color:var(--deep);font-weight:700}.moderation-provenance dl{margin-top:14px}.moderation-provenance p{margin:12px 0 0;color:var(--text-muted);font-size:12px;line-height:1.5} @media(max-width:900px){.admin-queue-filters{grid-template-columns:1fr 1fr}}@media(max-width:520px){.admin-queue-filters{grid-template-columns:1fr}} .admin-dashboard{width:min(1120px,calc(100% - 64px));margin:0 auto 100px}.admin-dashboard>.privacy{margin:10px 4px 25px}.admin-kpis{display:grid;grid-template-columns:repeat(4,1fr);gap:14px}.admin-kpis>a,.admin-kpis>article,.admin-dashboard-grid>section{padding:22px;border:1px solid var(--line);border-radius:14px;background:var(--surface)}.admin-kpis>a{color:inherit;text-decoration:none}.admin-kpis span,.admin-kpis small{display:block;color:var(--text-muted)}.admin-kpis strong{display:block;margin:10px 0;font:400 42px Georgia,serif;color:var(--deep)}.admin-kpis>a:hover{border-color:var(--focus);transform:translateY(-2px)}.admin-dashboard-grid{display:grid;grid-template-columns:1fr 1fr;gap:18px;margin-top:18px}.admin-dashboard-grid h2{margin:0 0 16px;font:400 25px Georgia,serif}.admin-dashboard-grid ul{list-style:none;margin:0 0 16px;padding:0}.admin-dashboard-grid li{display:flex;justify-content:space-between;gap:14px;padding:10px 0;border-bottom:1px solid var(--border-soft);font-size:13px}.admin-dashboard-grid li span{display:flex;align-items:center;gap:8px}.admin-dashboard-grid time{color:var(--text-muted)} @media(max-width:900px){.admin-kpis{grid-template-columns:1fr 1fr}}@media(max-width:720px){.admin-dashboard{width:calc(100% - 28px)}.admin-kpis,.admin-dashboard-grid{grid-template-columns:1fr}} diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index e74458e..2bf1dc5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -65,7 +65,7 @@ - [x] **M01 · Усиление административного входа.** Сохранён двойной барьер Caddy Basic Auth + API Bearer; внешние ссылки ограничены `http/https`, UI завершает сессию после 15 минут бездействия, предоставляет явный выход и возвращает вход после `401`. Неуспешная API-авторизация ограничена постоянным счётчиком по HMAC-идентификатору клиента с учётом доверенного proxy; успешный вход очищает ошибки клиента. Bearer-токен не сохраняется в URL, cookie или browser storage. - [x] **M02 · Единый dashboard.** `/admin` показывает счётчики pending-уловов и staging-наблюдений, число активных источников, их безопасные публичные статусы, последние импорты и быстрые переходы в очереди. Dashboard использует тот же memory-only токен и 15-минутную сессию, не выводит секреты, внутренние URL и полные тексты исключений. - [x] **M03 · Эффективность очередей.** Очередь внешних наблюдений получила серверные фильтры по источнику и полноте, безопасный поиск по рыбе/водоёму и сортировку по свежести или риску; проблемный порядок поднимает неполные и несопоставленные записи, а параметры работают до пагинации. Обе очереди блокируют всю карточку на время решения, сохраняют введённую причину при ошибке, явно подтверждают успех и переводят фокус к следующей записи. Безопасные горячие клавиши работают только внутри карточки с фокусом и отключены в полях ввода; отклонение и удаление намеренно оставлены только на кнопках. -- [ ] **M04 · Полный provenance и история решений — в работе.** Admin API отдаёт время первого/последнего обнаружения и проверки, явный список missing fields и allowlist безопасных скалярных полей исходной записи; неизвестные ключи, вложенные структуры и служебные данные не раскрываются. Далее: показать provenance перед публикацией, затем добавить read-only журнал решений и обезличенный экспорт. +- [ ] **M04 · Полный provenance и история решений — в работе.** Admin API отдаёт время первого/последнего обнаружения и проверки, явный список missing fields и allowlist безопасных скалярных полей исходной записи; неизвестные ключи, вложенные структуры и служебные данные не раскрываются. Карточка показывает эти сведения в раскрываемом блоке перед публикацией и напоминает сверить их с первоисточником. Далее: read-only журнал решений и обезличенный экспорт. - [ ] **M05 · Защита от параллельных решений.** Ввести version/updated-at precondition для optimistic locking и возвращать понятный `409`, если запись уже изменена другим модератором. - [ ] **M06 · Персональные роли — после пилота.** Если модераторов станет больше одного, заменить общий токен индивидуальными аккаунтами, короткими сессиями, отзывом доступа и ролями; писать идентификатор оператора в аудит. Для одного владельца альфы не добавлять отдельный auth-сервис заранее.