fix(sidebar,audit): badges для Outbox + Webhooks, clarify audit scope label

Sidebar:
- Outbox: бэйдж = outbox.pending (события queued, не отосланы). DLQ
  отдельно — alarming но не actionable из бэйджа, видно на /outbox.
- Webhooks: бэйдж = count активных subscriptions (w.active === true).
  User feedback: 'вебхуки активные есть а цифры с бэйджем нет'.

Audit log column header:
- 'Scope' → 'Доступ актора' (ru) / 'Actor scope' (en)
- Backend audit_log хранит userScope (claim из JWT актора), НЕ scope
  изменённого ресурса. User confusion: 'правки в public были а тут
  RESTRICTED' — потому что у пользователя scope-привилегия RESTRICTED.
  Уточнённая label убирает путаницу.
- TODO: backend feature — добавить dictionaryScope в AuditEntry чтобы
  показать обе ('Актор: RESTRICTED → Ресурс: PUBLIC' — двусторонний
  audit trail для compliance).
This commit is contained in:
Zimin A.N.
2026-05-13 11:03:47 +03:00
parent aecd65e7d5
commit aec2cdd644
2 changed files with 58 additions and 9 deletions
@@ -14,7 +14,13 @@ import {
} from 'lucide-react' } from 'lucide-react'
import { useAuth } from 'react-oidc-context' import { useAuth } from 'react-oidc-context'
import { useCanMutate } from '@/auth/useCanMutate' import { useCanMutate } from '@/auth/useCanMutate'
import { useDictionaries, useMyDrafts, useReviewsBadgeCount } from '@/api/queries' import {
useDictionaries,
useMyDrafts,
useOutboxStats,
useReviewsBadgeCount,
useWebhookSubscriptions,
} from '@/api/queries'
import { Sheet, SheetContent } from '@/ui' import { Sheet, SheetContent } from '@/ui'
import { ThemeSwitch } from '@/components/layout/ThemeSwitch' import { ThemeSwitch } from '@/components/layout/ThemeSwitch'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -56,13 +62,22 @@ export function Sidebar() {
// Loaded just for сидбар counter — light request, кэшируется. // Loaded just for сидбар counter — light request, кэшируется.
const myDrafts = useMyDrafts(0, 1) const myDrafts = useMyDrafts(0, 1)
// Reviews badge — суммирует record-reviews + schema-reviews pending queues // Reviews badge — суммирует record-reviews + schema-reviews pending queues
// через size=1 paged запросы (cheap: одна запись + metadata). Gated на // через size=1 paged запросы. Gated на authenticated.
// authenticated, чтобы anonymous не пытался admin endpoint'ы.
const reviews = useReviewsBadgeCount(auth.isAuthenticated) const reviews = useReviewsBadgeCount(auth.isAuthenticated)
// Outbox/Webhooks badges — light periodic poll. Backend гейтит, поэтому
// anonymous safe (просто 401 проигнорируется).
const outboxStats = useOutboxStats()
const webhooks = useWebhookSubscriptions()
const dictsCount = dictsQuery.data?.length ?? 0 const dictsCount = dictsQuery.data?.length ?? 0
const myDraftsCount = myDrafts.data?.totalElements ?? 0 const myDraftsCount = myDrafts.data?.totalElements ?? 0
const reviewsCount = reviews.total const reviewsCount = reviews.total
// Pending events ждут dispatch. dlq отдельно — alarming, но не блокирует
// нормальную работу, можно увидеть на /outbox. Badge показывает только
// pending (actionable: 'надо разобраться почему queue растёт').
const outboxPending = outboxStats.data?.pending ?? 0
const webhooksActive =
webhooks.data?.filter((w) => w.active).length ?? 0
// Flat list per redesign/compact.html — no section labels, /graph removed // Flat list per redesign/compact.html — no section labels, /graph removed
// (now reachable via catalog toolbar "Граф ⇄" button). Order: Главная → // (now reachable via catalog toolbar "Граф ⇄" button). Order: Главная →
@@ -91,8 +106,20 @@ export function Sidebar() {
badge: reviewsCount > 0 ? reviewsCount : undefined, badge: reviewsCount > 0 ? reviewsCount : undefined,
authRequired: true, authRequired: true,
}, },
{ to: '/outbox', label: t('nav.outbox'), icon: Inbox, authRequired: true }, {
{ to: '/webhooks', label: t('nav.webhooks'), icon: Webhook, authRequired: true }, to: '/outbox',
label: t('nav.outbox'),
icon: Inbox,
badge: outboxPending > 0 ? outboxPending : undefined,
authRequired: true,
},
{
to: '/webhooks',
label: t('nav.webhooks'),
icon: Webhook,
badge: webhooksActive > 0 ? webhooksActive : undefined,
authRequired: true,
},
{ to: '/audit', label: t('nav.audit'), icon: ScrollText, authRequired: true }, { to: '/audit', label: t('nav.audit'), icon: ScrollText, authRequired: true },
{ to: '/search', label: t('nav.search'), icon: Search }, { to: '/search', label: t('nav.search'), icon: Search },
], ],
@@ -394,10 +421,15 @@ export function MobileSidebar({ open, onClose }: { open: boolean; onClose: () =>
const dictsQuery = useDictionaries() const dictsQuery = useDictionaries()
const myDrafts = useMyDrafts(0, 1) const myDrafts = useMyDrafts(0, 1)
const reviews = useReviewsBadgeCount(auth.isAuthenticated) const reviews = useReviewsBadgeCount(auth.isAuthenticated)
const outboxStats = useOutboxStats()
const webhooks = useWebhookSubscriptions()
const dictsCount = dictsQuery.data?.length ?? 0 const dictsCount = dictsQuery.data?.length ?? 0
const myDraftsCount = myDrafts.data?.totalElements ?? 0 const myDraftsCount = myDrafts.data?.totalElements ?? 0
const reviewsCount = reviews.total const reviewsCount = reviews.total
const outboxPending = outboxStats.data?.pending ?? 0
const webhooksActive =
webhooks.data?.filter((w) => w.active).length ?? 0
// Flat list per redesign — same as desktop Sidebar above. // Flat list per redesign — same as desktop Sidebar above.
const sections: NavSection[] = [ const sections: NavSection[] = [
@@ -424,8 +456,20 @@ export function MobileSidebar({ open, onClose }: { open: boolean; onClose: () =>
badge: reviewsCount > 0 ? reviewsCount : undefined, badge: reviewsCount > 0 ? reviewsCount : undefined,
authRequired: true, authRequired: true,
}, },
{ to: '/outbox', label: t('nav.outbox'), icon: Inbox, authRequired: true }, {
{ to: '/webhooks', label: t('nav.webhooks'), icon: Webhook, authRequired: true }, to: '/outbox',
label: t('nav.outbox'),
icon: Inbox,
badge: outboxPending > 0 ? outboxPending : undefined,
authRequired: true,
},
{
to: '/webhooks',
label: t('nav.webhooks'),
icon: Webhook,
badge: webhooksActive > 0 ? webhooksActive : undefined,
authRequired: true,
},
{ to: '/audit', label: t('nav.audit'), icon: ScrollText, authRequired: true }, { to: '/audit', label: t('nav.audit'), icon: ScrollText, authRequired: true },
{ to: '/search', label: t('nav.search'), icon: Search }, { to: '/search', label: t('nav.search'), icon: Search },
], ],
+7 -2
View File
@@ -268,7 +268,12 @@ i18n
'audit.col.dictionary': 'Справочник', 'audit.col.dictionary': 'Справочник',
'audit.col.businessKey': 'Бизнес-ключ', 'audit.col.businessKey': 'Бизнес-ключ',
'audit.col.user': 'Пользователь', 'audit.col.user': 'Пользователь',
'audit.col.scope': 'Scope', // 'Scope' колонка показывает scope актора (claim из JWT), а НЕ
// scope ресурса. Backend audit log не хранит scope dictionary'я.
// User confusion: 'правки в public были, а тут RESTRICTED' —
// потому что у пользователя scope=RESTRICTED привилегия. Label
// 'Доступ актора' разъясняет это.
'audit.col.scope': 'Доступ актора',
'audit.col.trace': 'Trace', 'audit.col.trace': 'Trace',
'audit.col.diff': 'Изменения', 'audit.col.diff': 'Изменения',
'audit.action.expand': 'Раскрыть', 'audit.action.expand': 'Раскрыть',
@@ -972,7 +977,7 @@ i18n
'audit.col.dictionary': 'Dictionary', 'audit.col.dictionary': 'Dictionary',
'audit.col.businessKey': 'Business key', 'audit.col.businessKey': 'Business key',
'audit.col.user': 'User', 'audit.col.user': 'User',
'audit.col.scope': 'Scope', 'audit.col.scope': 'Actor scope',
'audit.col.trace': 'Trace', 'audit.col.trace': 'Trace',
'audit.col.diff': 'Diff', 'audit.col.diff': 'Diff',
'audit.action.expand': 'Expand', 'audit.action.expand': 'Expand',