From bd69b27e5c09932bac6e4d7dd54757cce4c03ecd Mon Sep 17 00:00:00 2001 From: Denozordec Date: Sat, 17 Oct 2026 22:14:30 +0700 Subject: [PATCH] =?UTF-8?q?feat(design-preview):=20KPI-=D0=BF=D0=BB=D0=B8?= =?UTF-8?q?=D1=82=D0=BA=D0=B8=20=D1=81=20=D0=B8=D0=BA=D0=BE=D0=BD=D0=BA?= =?UTF-8?q?=D0=B0=D0=BC=D0=B8,=20=D1=82=D0=BE=D0=BD=D0=B0=D0=BC=D0=B8=20?= =?UTF-8?q?=D0=B8=20=D0=BF=D0=BE=D1=8F=D1=81=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F?= =?UTF-8?q?=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Плитки собираются хелпером kpi(): иконка по тону, значение с пояснением, бейдж статуса. Для четырёх плиток сетка переходит на 2 колонки раньше (kpi-cols). Co-Authored-By: Claude Sonnet 5.5 --- apps/web/src/components/reui-kit/kpi-cols.ts | 2 +- apps/web/src/design-preview/kpi.tsx | 46 +++++++++++++++++++ .../design-preview/pages/app-links-page.tsx | 11 +++-- .../src/design-preview/pages/apps-page.tsx | 17 +++---- .../src/design-preview/pages/audit-page.tsx | 11 +++-- .../src/design-preview/pages/logins-page.tsx | 11 +++-- .../src/design-preview/pages/oidc-page.tsx | 11 +++-- .../design-preview/pages/security-page.tsx | 11 +++-- .../src/design-preview/pages/users-page.tsx | 10 ++-- 9 files changed, 89 insertions(+), 41 deletions(-) create mode 100644 apps/web/src/design-preview/kpi.tsx diff --git a/apps/web/src/components/reui-kit/kpi-cols.ts b/apps/web/src/components/reui-kit/kpi-cols.ts index cd8054a..980b973 100644 --- a/apps/web/src/components/reui-kit/kpi-cols.ts +++ b/apps/web/src/components/reui-kit/kpi-cols.ts @@ -3,7 +3,7 @@ export function kpiCols(count: number): string { if (count <= 1) return 'grid-cols-1' if (count === 2) return 'grid-cols-1 @xl:grid-cols-2' if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3' - if (count === 4) return 'grid-cols-1 @3xl:grid-cols-2 @6xl:grid-cols-4' + if (count === 4) return 'grid-cols-1 @xl:grid-cols-2 @6xl:grid-cols-4' if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5' if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6' return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4' diff --git a/apps/web/src/design-preview/kpi.tsx b/apps/web/src/design-preview/kpi.tsx new file mode 100644 index 0000000..e816841 --- /dev/null +++ b/apps/web/src/design-preview/kpi.tsx @@ -0,0 +1,46 @@ +import { createElement } from 'react' +import type { LucideIcon } from 'lucide-react' +import { Badge } from '@/components/reui/badge' +import type { KpiStatItem } from '@/components/reui-kit/kpi-stat-grid' + +export type KpiTone = 'neutral' | 'ok' | 'wn' | 'er' | 'in' + +const ICON_CLASS: Record = { + neutral: 'text-muted-foreground [&_svg]:text-current', + ok: 'text-success [&_svg]:text-current', + wn: 'text-warning [&_svg]:text-current', + er: 'text-destructive [&_svg]:text-current', + in: 'text-info [&_svg]:text-current', +} + +const BADGE: Record = { + neutral: 'outline', + ok: 'success-light', + wn: 'warning-light', + er: 'destructive-light', + in: 'info-light', +} + +/** Плитка KPI: иконка по тону, значение с пояснением, бейдж статуса. */ +export function kpi( + id: string, + label: string, + value: string, + opts: { icon: LucideIcon; tone?: KpiTone; badge: string; sub?: string }, +): KpiStatItem { + const tone = opts.tone ?? 'neutral' + return { + id, + label, + icon: createElement(opts.icon), + iconClassName: ICON_CLASS[tone], + variant: tone === 'wn' ? 'warning' : tone === 'er' ? 'destructive' : 'default', + value: createElement( + 'span', + { className: 'flex flex-wrap items-baseline gap-x-2' }, + value, + opts.sub ? createElement('span', { className: 'text-muted-foreground text-xs font-normal' }, opts.sub) : null, + ), + footer: createElement(Badge, { variant: BADGE[tone], size: 'sm', className: 'whitespace-nowrap' }, opts.badge), + } +} diff --git a/apps/web/src/design-preview/pages/app-links-page.tsx b/apps/web/src/design-preview/pages/app-links-page.tsx index aa870cd..d6a7f1f 100644 --- a/apps/web/src/design-preview/pages/app-links-page.tsx +++ b/apps/web/src/design-preview/pages/app-links-page.tsx @@ -1,5 +1,5 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { ArrowDownIcon, ArrowUpIcon, PlusIcon } from 'lucide-react' +import { ArrowDownIcon, ArrowUpIcon, PlusIcon, LayoutGridIcon, EyeIcon, KeyRoundIcon, FingerprintIcon } from 'lucide-react' import { toast } from 'sonner' import { APPS, type AppId } from '@authportal/shared' import { AppSwitcherAdminEditor } from '@/components/reui-kit/app-switcher-admin-editor' @@ -10,6 +10,7 @@ import { Input } from '@authportal/ui/components/input' import { Switch } from '@authportal/ui/components/switch' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' import { PageHead, PreviewSheet, StatusBadge } from '../bits' +import { kpi } from '../kpi' import { MOCK_SWITCHER } from '../mock' import { usePreviewStore } from '../preview-store' import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid' @@ -120,10 +121,10 @@ export function AppLinksPage() { r.mode === 'JWT').length), hint: 'fragment-токен' }, - { id: 'oidc', label: 'OIDC', value: String(rows.filter((r) => r.mode === 'OIDC').length), hint: 'Technitium DNS' }, + kpi('n', 'Приложений', String(rows.length), { icon: LayoutGridIcon, badge: 'в каталоге', sub: 'сервисов' }), + kpi('sw', 'В переключателе', String(shown), { icon: EyeIcon, tone: 'ok', badge: 'видны всем', sub: `из ${rows.length}` }), + kpi('jwt', 'JWT SSO', String(rows.filter((r) => r.mode === 'JWT').length), { icon: KeyRoundIcon, tone: 'in', badge: 'fragment-токен', sub: 'без пароля' }), + kpi('oidc', 'OIDC', String(rows.filter((r) => r.mode === 'OIDC').length), { icon: FingerprintIcon, tone: 'in', badge: 'Technitium DNS', sub: 'внешний RP' }), ]} /> diff --git a/apps/web/src/design-preview/pages/apps-page.tsx b/apps/web/src/design-preview/pages/apps-page.tsx index b4c170b..8725a20 100644 --- a/apps/web/src/design-preview/pages/apps-page.tsx +++ b/apps/web/src/design-preview/pages/apps-page.tsx @@ -1,11 +1,5 @@ import { Link } from '@tanstack/react-router' -import { - CloudIcon, - GlobeIcon, - LayoutDashboardIcon, - ServerIcon, - ShieldIcon, -} from 'lucide-react' +import { CloudIcon, GlobeIcon, LayoutDashboardIcon, ServerIcon, ShieldIcon, LayoutGridIcon, KeyRoundIcon, FingerprintIcon, ShieldCheckIcon } from 'lucide-react' import { toast } from 'sonner' import { APPS, type AppId } from '@authportal/shared' import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' @@ -13,6 +7,7 @@ import { QuickActionGrid } from '@/components/reui-kit/quick-action-grid' import { Button } from '@authportal/ui/components/button' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' import { PageHead } from '../bits' +import { kpi } from '../kpi' const ICONS: Record = { cfdm: CloudIcon, @@ -45,10 +40,10 @@ export function AppsPage() { a.authMode === 'jwt').length), hint: 'fragment-токен' }, - { id: 'oidc', label: 'OIDC', value: String(allowed.filter((a) => a.authMode === 'oidc').length), hint: 'Technitium DNS' }, - { id: 'role', label: 'Роль', value: 'Админ', hint: 'is_admin' }, + kpi('avail', 'Доступно приложений', String(allowed.length), { icon: LayoutGridIcon, tone: 'ok', badge: 'доступ есть', sub: `из ${APPS.length}` }), + kpi('jwt', 'JWT SSO', String(allowed.filter((a) => a.authMode === 'jwt').length), { icon: KeyRoundIcon, tone: 'in', badge: 'fragment-токен', sub: 'вход без пароля' }), + kpi('oidc', 'OIDC', String(allowed.filter((a) => a.authMode === 'oidc').length), { icon: FingerprintIcon, tone: 'in', badge: 'Technitium DNS', sub: 'внешний RP' }), + kpi('role', 'Роль', 'Админ', { icon: ShieldCheckIcon, tone: 'wn', badge: 'is_admin', sub: 'полный доступ' }), ]} /> diff --git a/apps/web/src/design-preview/pages/audit-page.tsx b/apps/web/src/design-preview/pages/audit-page.tsx index be9612e..ee926c2 100644 --- a/apps/web/src/design-preview/pages/audit-page.tsx +++ b/apps/web/src/design-preview/pages/audit-page.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from 'react' -import { DownloadIcon, Trash2Icon } from 'lucide-react' +import { DownloadIcon, Trash2Icon, HistoryIcon, UserCogIcon, ShieldAlertIcon, NetworkIcon } from 'lucide-react' import { toast } from 'sonner' import type { AuditLogEntry, AuditSeverity } from '@authportal/shared' import { AuditLogTimeline } from '@/components/reui-kit/audit-log-timeline' @@ -8,6 +8,7 @@ import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' import { Button } from '@authportal/ui/components/button' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' import { Segmented, CodeBlock, KvList, PageHead, PreviewSheet, StatusBadge, TypedConfirm } from '../bits' +import { kpi } from '../kpi' import { MOCK_AUDIT, APP_SHORT, type Tone } from '../mock' import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid' @@ -89,10 +90,10 @@ export function AuditPage() { e.severity === 'critical').length), hint: 'critical', variant: 'destructive' }, - { id: 'src', label: 'Источников', value: '6', hint: 'портал + 5 приложений' }, + kpi('all', 'Событий', '148', { icon: HistoryIcon, badge: 'за 7 дней', sub: 'во всех источниках' }), + kpi('acc', 'Изменений прав', '22', { icon: UserCogIcon, tone: 'ok', badge: 'user.access_update', sub: 'в портале' }), + kpi('crit', 'Критичных', String(MOCK_AUDIT.filter((e) => e.severity === 'critical').length), { icon: ShieldAlertIcon, tone: 'er', badge: 'critical', sub: 'требуют внимания' }), + kpi('src', 'Источников', '6', { icon: NetworkIcon, tone: 'in', badge: 'ingest', sub: 'портал + 5 приложений' }), ]} /> diff --git a/apps/web/src/design-preview/pages/logins-page.tsx b/apps/web/src/design-preview/pages/logins-page.tsx index d7c8d8f..589bdc4 100644 --- a/apps/web/src/design-preview/pages/logins-page.tsx +++ b/apps/web/src/design-preview/pages/logins-page.tsx @@ -1,11 +1,12 @@ import { useMemo, useState } from 'react' import { Link, useNavigate } from '@tanstack/react-router' -import { DownloadIcon } from 'lucide-react' +import { DownloadIcon, LogInIcon, ShieldAlertIcon, GlobeIcon, MonitorSmartphoneIcon } from 'lucide-react' import { toast } from 'sonner' import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' import { Button } from '@authportal/ui/components/button' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' import { Segmented, CodeBlock, KvList, PageHead, PreviewSheet, SignalAlert, StatusBadge } from '../bits' +import { kpi } from '../kpi' import { MOCK_LOGINS, MOCK_SESSIONS, type LoginRow, type SessionRow } from '../mock' import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid' @@ -87,10 +88,10 @@ export function LoginsPage() { diff --git a/apps/web/src/design-preview/pages/oidc-page.tsx b/apps/web/src/design-preview/pages/oidc-page.tsx index 33b484f..e4bb76c 100644 --- a/apps/web/src/design-preview/pages/oidc-page.tsx +++ b/apps/web/src/design-preview/pages/oidc-page.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from 'react' -import { CopyIcon, PlusIcon, RefreshCwIcon } from 'lucide-react' +import { CopyIcon, PlusIcon, RefreshCwIcon, AppWindowIcon, PowerIcon, TicketIcon, KeyRoundIcon } from 'lucide-react' import { toast } from 'sonner' import { CreateOidcClientSheet, type CreateOidcClientValues } from '@/components/reui-kit/create-oidc-client-sheet' import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' @@ -18,6 +18,7 @@ import { Switch } from '@authportal/ui/components/switch' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' import { Textarea } from '@authportal/ui/components/textarea' import { CopyList, KvFrame, PageHead, PreviewSheet, SignalAlert, StatusBadge, TypedConfirm } from '../bits' +import { kpi } from '../kpi' import { MOCK_CLIENT_SECRET, MOCK_OIDC_CLIENTS, @@ -113,10 +114,10 @@ export function OidcPage() { c.enabled).length), hint: 'принимают вход' }, - { id: 'tok', label: 'Токенов за 24 ч', value: '41', hint: 'authorization_code' }, - { id: 'key', label: 'Ключ подписи', value: 'RS256', hint: 'ротация вручную' }, + kpi('n', 'Клиентов', String(clients.length), { icon: AppWindowIcon, badge: 'зарегистрировано', sub: 'внешних RP' }), + kpi('on', 'Включено', String(clients.filter((c) => c.enabled).length), { icon: PowerIcon, tone: 'ok', badge: 'принимают вход', sub: 'активны' }), + kpi('tok', 'Токенов за 24 ч', '41', { icon: TicketIcon, tone: 'in', badge: 'authorization_code', sub: 'выдано' }), + kpi('key', 'Ключ подписи', 'RS256', { icon: KeyRoundIcon, tone: staleCount > 0 ? 'wn' : 'ok', badge: 'ротация вручную', sub: 'JWKS активен' }), ]} /> diff --git a/apps/web/src/design-preview/pages/security-page.tsx b/apps/web/src/design-preview/pages/security-page.tsx index b500aba..e2c7f88 100644 --- a/apps/web/src/design-preview/pages/security-page.tsx +++ b/apps/web/src/design-preview/pages/security-page.tsx @@ -1,5 +1,5 @@ import { useMemo, useState } from 'react' -import { KeyRoundIcon, PlusIcon, TrashIcon } from 'lucide-react' +import { KeyRoundIcon, PlusIcon, TrashIcon, FingerprintIcon, ClockIcon, LockKeyholeIcon, MonitorSmartphoneIcon } from 'lucide-react' import { toast } from 'sonner' import { KpiStatGrid } from '@/components/reui-kit/kpi-stat-grid' import { Frame, FrameDescription, FrameHeader, FramePanel, FrameTitle } from '@/components/reui/frame' @@ -9,6 +9,7 @@ import { Input } from '@authportal/ui/components/input' import { Switch } from '@authportal/ui/components/switch' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@authportal/ui/components/tabs' import { KvFrame, KvList, PageHead, PreviewSheet, SignalAlert, StatusBadge, TypedConfirm } from '../bits' +import { kpi } from '../kpi' import { MOCK_PASSKEYS, type PasskeyRow } from '../mock' import { PreviewSectionGrid, TwoLineCell, type GridColumn } from '../section-grid' @@ -52,10 +53,10 @@ export function SecurityPage() { 1 ? 'ok' : 'wn', badge: keys.length > 1 ? 'резерв есть' : 'одна копия', sub: 'активны' }), + kpi('last', 'Последний вход', 'сегодня', { icon: ClockIcon, badge: 'Touch ID', sub: '09:41' }), + kpi('pw', 'Пароль', 'Задан', { icon: LockKeyholeIcon, tone: 'ok', badge: 'запасной вход', sub: 'сменён 11.03' }), + kpi('sess', 'Сессий', '3', { icon: MonitorSmartphoneIcon, badge: 'устройства', sub: 'сейчас активны' }), ]} /> diff --git a/apps/web/src/design-preview/pages/users-page.tsx b/apps/web/src/design-preview/pages/users-page.tsx index 5cccd59..f12eccf 100644 --- a/apps/web/src/design-preview/pages/users-page.tsx +++ b/apps/web/src/design-preview/pages/users-page.tsx @@ -1,3 +1,4 @@ +import { UsersIcon, ShieldCheckIcon, FingerprintIcon, UserXIcon } from 'lucide-react' import { useCallback, useEffect, useRef, useState } from 'react' import { Link } from '@tanstack/react-router' import { toast } from 'sonner' @@ -9,6 +10,7 @@ import { Button } from '@authportal/ui/components/button' import { AccessDialog } from '../access-dialog' import { diffAccess, type AccessValue } from '../access-model' import { CodeBlock, KvList, PageHead, PreviewSheet, SignalAlert, TypedConfirm } from '../bits' +import { kpi } from '../kpi' import { MOCK_USERS, USER_JOURNAL } from '../mock' import { usePreviewStore } from '../preview-store' @@ -126,10 +128,10 @@ export function UsersPage() { 0 ? 'wn' : 'ok', badge: 'только пароль', sub: 'нет резервного входа' }), + kpi('off', 'Отключённых', String(disabled), { icon: UserXIcon, badge: 'вход закрыт', sub: 'не удаляются' }), ]} />