fix(ui): добавить монитор системы и выровнять chrome
Build and Push Auth Portal Docker Image / build-and-push (push) Successful in 2m18s
Build and Push Auth Portal Docker Image / create-release (push) Skipped

Подключить SystemMonitor, IconTile, QuickActionGrid на /apps и skip-link.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Denozordec
2026-08-17 15:36:55 +07:00
co-authored by Cursor
parent 4ce05a6669
commit 778c42bad8
17 changed files with 687 additions and 109 deletions
+10 -9
View File
@@ -1,11 +1,11 @@
---
description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12). Запрет SectionCards и hand-roll.
description: Только hybrid KPI — KpiStatGrid / row tile DNA (stats-12 + IconTile). Запрет SectionCards и hand-roll.
alwaysApply: true
---
# KPI hybrid — только kit (stats-12 DNA)
# KPI hybrid — только kit (stats-12 + IconTile)
Preview: [stats-12](https://reui.io/preview/base/stats-12). SoT DNA = EvoBGP. Markup в проекте: `apps/web/src/components/reui-kit/kpi-stat-grid.tsx`.
Preview: [stats-12](https://reui.io/preview/base/stats-12). Primitive: [icon-tile](https://reui.io/docs/components/base/icon-tile). SoT DNA = EvoBGP. Markup: `apps/web/src/components/reui-kit/kpi-stat-grid.tsx`.
Связанные: [`reui-mcp.mdc`](reui-mcp.mdc), [`frontend-ui-patterns.mdc`](frontend-ui-patterns.mdc).
@@ -14,16 +14,17 @@ Preview: [stats-12](https://reui.io/preview/base/stats-12). SoT DNA = EvoBGP. Ma
| Зона | Компонент / DNA |
|------|-----------------|
| KPI-полосы / dashboard metrics | только `reui-kit/KpiStatGrid` (через `OpsDashboard` / `DetailPanel.Metrics` при наличии) |
| Markup | horizontal compact hybrid: icon left `Item` `size-10.5` `bg-muted` + `border-background` + shadow + `ItemMedia` + label/Badge + value ± `variant` |
| Row icon tiles (data-grid) | та же DNA — semantic `text-*` на `bg-muted` |
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA) |
| Markup | horizontal compact hybrid: icon left `IconTile` `variant="elevated"` `className="size-10.5"` + label/Badge + value ± `variant` |
| Row icon tiles (data-grid) | та же DNA — semantic `text-*` на IconTile elevated |
| Quick Actions | только `reui-kit/QuickActionGrid` (sibling hybrid DNA, IconTile elevated) |
Импорты UI: `@authportal/ui/components/*`.
Импорты UI: `@authportal/ui/components/*`. IconTile: `@/components/reui/icon-tile`.
## NEVER
- SectionCards / vertical-only KPI / hand-roll Frame/Card KPI
- Другой size / radius / solid brand fill вместо `bg-muted`
- `Item` `size-10.5` `bg-muted` hybrid вместо IconTile
- Другой size / radius / solid brand fill вместо `elevated`
- `card-35` как замена stats-12 hybrid KPI
- Копипаст ReUI block в route — adapt через `reui-kit/`
- Голый lucide `size-4` в name-cell без hybrid tile
- Голый lucide `size-4` в name-cell без IconTile
+2 -2
View File
@@ -75,9 +75,9 @@ alwaysApply: true
## Установленные ReUI (apps/web)
**Components:** `frame`, `data-grid/*`, `filters`, `kanban`, `badge`, `alert`, `autocomplete`, `number-field`, `date-selector`, `color-picker`, `timeline`, `rating`, `phone-input`, `icon-stack`
**Components:** `frame`, `data-grid/*`, `filters`, `badge`, `alert`, `timeline`, `icon-tile`, `icon-stack`
**Kit:** `ResourcePage`, `KpiStatGrid`, `QuickActionGrid`, `OpsDashboard`, `SettingsShell`, `DetailPanel`, `SettingsShell`
**Kit:** `KpiStatGrid`, `QuickActionGrid`, `AdminUsersGrid` (lists = PageShell + Frame + DataGrid; `ResourcePage` не list-kit)
**Blocks (reference):** `stats-12`, `card-35`, `auth-13`, `app-shell-12`, `settings-16`, `settings-8`, `empty-state-12`, `form-7`, `data-grid-filtering-2`, `dashboard-1`, …
+2 -2
View File
@@ -22,8 +22,8 @@
- Skill: `.claude/skills/reui` (`668fb463eb`, 20 free components)
- Rules: `.cursor/rules/reui-mcp.mdc`, `frontend-shadcn.mdc`, `frontend-ui-patterns.mdc`, `shadcn-mcp.mdc`, `shadcn-ui-production.mdc`
- Primary MCP: `user-reui`; primitives: `plugin-shadcn-shadcn`
- Chrome: NavUser in sidebar footer (theme segmented); no ModeToggle in header
- Kit: `KpiStatGrid`, `QuickActionGrid` (SoT markup = EvoBGP); lists = PageShell + Frame + DataGrid
- Chrome: header AppsMenu → SystemMonitorPopover; NavUser in sidebar footer (theme segmented); no ModeToggle in header
- Kit: `KpiStatGrid`, `QuickActionGrid` (SoT markup = EvoBGP + IconTile); lists = `AdminUsersGrid` / PageShell + Frame + DataGrid
## License
+82
View File
@@ -0,0 +1,82 @@
/**
* ReUI Empty + IconStack — Frame-friendly (empty-state-14 / empty-state-12).
* Preview: https://reui.io/preview/base/empty-state-14 · https://reui.io/preview/base/empty-state-12
* Docs: https://reui.io/docs/components/base/icon-stack
*/
import { InboxIcon, type LucideIcon } from 'lucide-react'
import { IconStack } from '@/components/reui/icon-stack'
import {
Empty,
EmptyContent,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from '@authportal/ui/components/empty'
import { cn } from '@authportal/ui/lib/utils'
import type { ReactNode } from 'react'
interface EmptyStateProps {
icon?: LucideIcon
title: string
description?: string
action?: ReactNode
className?: string
stackedIcon?: boolean
centered?: boolean
}
export function EmptyState({
icon: Icon = InboxIcon,
title,
description,
action,
className,
stackedIcon = true,
centered = true,
}: EmptyStateProps) {
const body = (
<Empty
className={cn(
'max-w-md flex-none border-0 bg-transparent p-0',
!centered && className,
)}
>
<EmptyHeader className="gap-5 text-center">
<EmptyMedia className="mb-0">
{stackedIcon ? (
<IconStack aria-hidden="true" className="h-14 w-12">
<Icon strokeWidth={1.9} aria-hidden="true" className="size-5" />
</IconStack>
) : (
<span className="bg-muted text-muted-foreground flex size-10 items-center justify-center rounded-lg [&_svg]:size-5">
<Icon aria-hidden="true" />
</span>
)}
</EmptyMedia>
<div className="flex flex-col items-center gap-2">
<EmptyTitle className="text-base font-semibold tracking-tight">{title}</EmptyTitle>
{description ? (
<EmptyDescription className="max-w-sm text-sm/relaxed">{description}</EmptyDescription>
) : null}
</div>
</EmptyHeader>
{action ? (
<EmptyContent className="mt-1 items-center justify-center">{action}</EmptyContent>
) : null}
</Empty>
)
if (!centered) return body
return (
<div
className={cn(
'flex w-full flex-1 items-center justify-center py-14 sm:py-16',
className,
)}
>
{body}
</div>
)
}
+9 -3
View File
@@ -4,6 +4,9 @@ import { SiteHeader } from '@/components/layout/site-header'
import { TooltipProvider } from '@authportal/ui/components/tooltip'
import { SidebarInset, SidebarProvider } from '@authportal/ui/components/sidebar'
const SKIP_TO_CONTENT_CLASS =
'bg-background text-foreground ring-ring sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:rounded-md focus:px-3 focus:py-2 focus:text-sm focus:font-medium focus:shadow-sm focus:ring-2'
/** Shared ops chrome — etalon app-shell-12. */
export function AppShell({ children }: { children: ReactNode }) {
return (
@@ -15,12 +18,15 @@ export function AppShell({ children }: { children: ReactNode }) {
} as CSSProperties
}
>
<a href="#main-content" className={SKIP_TO_CONTENT_CLASS}>
К содержимому
</a>
<AppSidebar />
<SidebarInset>
<SidebarInset id="main-content">
<SiteHeader />
<main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
<div className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
{children}
</main>
</div>
</SidebarInset>
</SidebarProvider>
</TooltipProvider>
@@ -11,6 +11,7 @@ import {
import { Separator } from '@authportal/ui/components/separator'
import { SidebarTrigger } from '@authportal/ui/components/sidebar'
import { AppsMenu } from '@/components/layout/apps-menu'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
function breadcrumbs(pathname: string) {
if (pathname.startsWith('/admin/apps')) {
@@ -45,9 +46,12 @@ export function SiteHeader() {
const crumbs = useMemo(() => breadcrumbs(pathname), [pathname])
return (
<header className="bg-background sticky top-0 z-20 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
<SidebarTrigger className="-ml-1" />
<Separator orientation="vertical" className="mr-1 h-4" />
<Separator
orientation="vertical"
className="mr-2 data-[orientation=vertical]:h-4"
/>
<Breadcrumb>
<BreadcrumbList>
{crumbs.map((crumb, i) => {
@@ -70,8 +74,9 @@ export function SiteHeader() {
</BreadcrumbList>
</Breadcrumb>
<div className="ml-auto flex items-center gap-1">
<div className="ml-auto flex items-center gap-2">
<AppsMenu />
<SystemMonitorPopover />
</div>
</header>
)
@@ -0,0 +1,243 @@
import { useMemo, type ReactNode } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Activity, AppWindow, KeyRound, Users } from 'lucide-react'
import { Badge } from '@/components/reui/badge'
import { cn } from '@authportal/ui/lib/utils'
import { Item, ItemMedia } from '@authportal/ui/components/item'
import { Popover, PopoverContent, PopoverTrigger } from '@authportal/ui/components/popover'
import { catalogQueryOptions, meQueryOptions, usersQueryOptions } from '@/queries/auth'
import { oidcClientsQueryOptions } from '@/queries/oidc'
type MonitorMetric = {
id: string
label: string
value: string
unit: string
percent: number
icon: ReactNode
tone: 'success' | 'warning' | 'destructive' | 'info'
alert: boolean
}
function toneColor(tone: MonitorMetric['tone']) {
switch (tone) {
case 'success':
return 'var(--color-success)'
case 'warning':
return 'var(--color-warning)'
case 'destructive':
return 'var(--color-destructive)'
default:
return 'var(--color-info)'
}
}
function MetricBar({ percent, color }: { percent: number; color: string }) {
return (
<div className="bg-muted h-1 w-full overflow-hidden rounded-full">
<div
className="h-full rounded-full"
style={{
width: `${Math.min(100, Math.max(0, percent))}%`,
backgroundColor: color,
}}
/>
</div>
)
}
function MetricCell({ metric }: { metric: MonitorMetric }) {
const color = toneColor(metric.tone)
return (
<div className="flex flex-col gap-2 p-3">
<div className="flex items-center justify-between gap-1">
<div className="flex min-w-0 items-center gap-1.5">
<Item
className="flex size-5 shrink-0 items-center justify-center p-0"
style={{ backgroundColor: `${color}18` }}
>
<ItemMedia variant="icon" className="size-auto" style={{ color }}>
{metric.icon}
</ItemMedia>
</Item>
<span className="text-muted-foreground truncate text-[11px]">{metric.label}</span>
</div>
<span className="shrink-0 text-xs font-semibold tabular-nums" style={{ color }}>
{metric.value}
<span className="text-muted-foreground ml-0.5 text-[10px] font-normal">{metric.unit}</span>
</span>
</div>
<MetricBar percent={metric.percent} color={color} />
</div>
)
}
/** Live system monitor popover — app-shell-7, Auth Portal metrics. @see https://reui.io/preview/base/app-shell-7 */
export function SystemMonitorPopover() {
const meQ = useQuery(meQueryOptions)
const isAdmin = Boolean(meQ.data?.is_admin)
const readyQ = useQuery({
queryKey: ['ready', 'monitor'],
queryFn: async () => {
const res = await fetch('/ready')
if (!res.ok) throw new Error('not ready')
return (await res.json()) as { ok?: boolean }
},
refetchInterval: 30_000,
retry: false,
})
const catalogQ = useQuery({ ...catalogQueryOptions, refetchInterval: 30_000 })
const usersQ = useQuery({
...usersQueryOptions,
enabled: isAdmin,
retry: false,
refetchInterval: 30_000,
})
const oidcQ = useQuery({
...oidcClientsQueryOptions,
enabled: isAdmin,
retry: false,
refetchInterval: 30_000,
})
const apiOk = readyQ.data?.ok === true || readyQ.isSuccess
const appsCount = catalogQ.data?.apps.length ?? 0
const usersCount = isAdmin ? (usersQ.data?.length ?? 0) : null
const oidcCount = isAdmin ? (oidcQ.data?.length ?? 0) : null
const disabledUsers = isAdmin
? (usersQ.data ?? []).filter((u) => u.disabled).length
: 0
const metrics = useMemo<MonitorMetric[]>(
() => [
{
id: 'ready',
label: 'API',
value: apiOk ? 'OK' : '—',
unit: '',
percent: apiOk ? 100 : 15,
icon: <Activity aria-hidden />,
tone: apiOk ? 'success' : 'destructive',
alert: !apiOk,
},
{
id: 'apps',
label: 'Приложения',
value: String(appsCount),
unit: 'шт.',
percent: Math.min(100, appsCount * 20),
icon: <AppWindow aria-hidden />,
tone: appsCount > 0 ? 'success' : 'warning',
alert: appsCount === 0,
},
{
id: 'users',
label: 'Пользователи',
value: usersCount == null ? '—' : String(usersCount),
unit: usersCount == null ? '' : 'шт.',
percent:
usersCount == null ? 0 : Math.min(100, Math.max(20, usersCount * 10)),
icon: <Users aria-hidden />,
tone:
usersCount == null
? 'info'
: disabledUsers > 0
? 'warning'
: 'success',
alert: disabledUsers > 0,
},
{
id: 'oidc',
label: 'OIDC',
value: oidcCount == null ? '—' : String(oidcCount),
unit: oidcCount == null ? '' : 'шт.',
percent: oidcCount == null ? 0 : Math.min(100, Math.max(15, oidcCount * 25)),
icon: <KeyRound aria-hidden />,
tone: oidcCount == null ? 'info' : oidcCount > 0 ? 'success' : 'warning',
alert: false,
},
],
[apiOk, appsCount, disabledUsers, oidcCount, usersCount],
)
const spiking = metrics.some((m) => m.alert) || !apiOk
return (
<Popover>
<PopoverTrigger
render={
<button
type="button"
aria-label="Монитор системы"
className={cn(
'relative inline-flex h-8 items-center gap-1.5 rounded-md border px-2 transition-colors outline-none',
'border-border hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring',
)}
/>
}
>
<span className="relative flex size-3.5 items-center justify-center">
<Activity
aria-hidden
className={cn(
'size-3.5 transition-colors',
spiking ? 'text-destructive' : 'text-muted-foreground',
)}
/>
{spiking ? (
<span className="bg-destructive/25 absolute inset-0 animate-ping rounded-full" aria-hidden />
) : null}
</span>
<span className="text-foreground hidden text-xs font-medium sm:inline">Система</span>
<Badge
variant={spiking ? 'destructive-light' : 'success-light'}
size="xs"
className="h-4 px-1.5 text-[10px]"
>
{spiking ? 'Внимание' : 'Норма'}
</Badge>
</PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
<span className="text-foreground text-xs font-medium">Монитор Auth Portal</span>
<span className="text-muted-foreground text-[11px] tabular-nums">
{new Date().toLocaleTimeString('ru-RU')}
</span>
</div>
<div className="grid grid-cols-2">
{metrics.map((metric, i) => (
<div
key={metric.id}
className={cn(
i % 2 === 1 && 'border-border border-l',
i >= 2 && 'border-border border-t',
)}
>
<MetricCell metric={metric} />
</div>
))}
</div>
<div className="border-border text-muted-foreground border-t px-3 py-2 text-[11px]">
API:{' '}
<span className="text-foreground font-medium">{apiOk ? 'OK' : '—'}</span>
{isAdmin ? (
<>
{' · '}
Отключено:{' '}
<span className="text-foreground font-medium tabular-nums">{disabledUsers}</span>
</>
) : (
<>
{' · '}
Метрики пользователей для админа
</>
)}
</div>
</PopoverContent>
</Popover>
)
}
@@ -10,7 +10,6 @@ export {
type OpsKpiCard,
} from './kpi-stat-grid'
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
export type { ResourcePageProps, ResourcePageTab } from './resource-page'
export { AppSwitcherAdminEditor } from './app-switcher-admin-editor'
export { UserAccessSheet } from './user-access-sheet'
export { UserAuditSheet } from './user-audit-sheet'
@@ -5,7 +5,7 @@ import { Frame, FramePanel } from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { cn } from '@authportal/ui/lib/utils'
import { kpiCols } from './kpi-cols'
import { Item, ItemMedia } from '@authportal/ui/components/item'
import { IconTile } from '@/components/reui/icon-tile'
import { Skeleton } from '@authportal/ui/components/skeleton'
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
@@ -83,16 +83,13 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
return (
<div className="relative z-10 flex h-full items-start gap-3">
{item.icon ? (
<Item
className={cn(
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
item.iconClassName ?? DEFAULT_ICON_CLASS,
)}
<IconTile
variant="elevated"
aria-hidden="true"
className={cn('size-10.5', item.iconClassName ?? DEFAULT_ICON_CLASS)}
>
<ItemMedia variant="icon" className="size-auto">
{item.icon}
</ItemMedia>
</Item>
{item.icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
@@ -8,7 +8,7 @@ import {
FrameTitle,
} from '@/components/reui/frame'
import { Badge } from '@/components/reui/badge'
import { Item, ItemMedia } from '@authportal/ui/components/item'
import { IconTile } from '@/components/reui/icon-tile'
import { cn } from '@authportal/ui/lib/utils'
import { kpiCols } from './kpi-cols'
@@ -16,8 +16,10 @@ export interface QuickActionItem {
id: string
title: string
description: string
to: string
to?: string
search?: Record<string, unknown>
onSelect?: () => void
badgeLabel?: string
icon?: ReactNode
iconClassName?: string
}
@@ -35,23 +37,20 @@ function QuickActionBody({ action }: { action: QuickActionItem }) {
return (
<div className="relative z-10 flex h-full items-start gap-3">
{action.icon ? (
<Item
className={cn(
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
action.iconClassName ?? DEFAULT_ICON_CLASS,
)}
<IconTile
variant="elevated"
aria-hidden="true"
className={cn('size-10.5', action.iconClassName ?? DEFAULT_ICON_CLASS)}
>
<ItemMedia variant="icon" className="size-auto">
{action.icon}
</ItemMedia>
</Item>
{action.icon}
</IconTile>
) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex items-start justify-between gap-2">
<span className="text-foreground text-sm font-medium">{action.title}</span>
<Badge variant="outline" size="sm" className="shrink-0">
Перейти
{action.badgeLabel ?? 'Перейти'}
</Badge>
</div>
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
@@ -62,9 +61,40 @@ function QuickActionBody({ action }: { action: QuickActionItem }) {
)
}
function QuickActionHit({ action }: { action: QuickActionItem }) {
const label = `${action.title}: ${action.description}`
const body = <QuickActionBody action={action} />
if (action.onSelect) {
return (
<button
type="button"
className="w-full text-left focus-visible:outline-none"
aria-label={label}
onClick={action.onSelect}
>
{body}
</button>
)
}
if (!action.to) return body
return (
<Link
to={action.to}
search={action.search}
className="focus-visible:outline-none"
aria-label={label}
>
{body}
</Link>
)
}
/**
* KPI-like quick actions strip (horizontal Frame tiles).
* Preview: https://reui.io/preview/base/stats-12
* Preview: https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/card-12
*/
export function QuickActionGrid({
actions,
@@ -88,14 +118,7 @@ export function QuickActionGrid({
key={action.id}
className="relative isolate flex h-full flex-col hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2"
>
<Link
to={action.to}
search={action.search}
className="focus-visible:outline-none"
aria-label={`${action.title}: ${action.description}`}
>
<QuickActionBody action={action} />
</Link>
<QuickActionHit action={action} />
</FramePanel>
))}
</div>
@@ -1,7 +1,8 @@
/**
* Portal lists compose PageShell + Frame + DataGrid directly.
* Types kept for kit parity with ops apps (CFDM / VPS / EvoBGP).
* Preview: https://reui.io/preview/base/data-grid-filtering-2
* Portal lists are not ResourcePage.
* Users: `AdminUsersGrid` — DNA [solution-users-1](https://reui.io/preview/base/solution-users-1).
* Other admin tables: PageShell + Frame + DataGrid in the route.
* Types kept for kit parity with ops apps — do not add a ResourcePage component here.
*/
export type ResourcePageTab = {
id: string
@@ -0,0 +1,90 @@
import { cn } from "@authportal/ui/lib/utils"
type IconStackProps = React.ComponentProps<"div">
function IconStack({ className, children, style, ...props }: IconStackProps) {
return (
<div
data-slot="icon-stack"
className={cn(
"text-foreground **:data-[slot=icon-stack-layer]:fill-background relative h-20 w-18",
className
)}
style={
{
"--icon-stack-content-x": "71%",
"--icon-stack-content-y": "58%",
...style,
} as React.CSSProperties
}
{...props}
>
<svg
aria-hidden="true"
viewBox="0 0 72 81"
fill="none"
className="h-full w-full overflow-visible"
>
<ellipse
cx="36"
cy="76"
rx="30"
ry="7"
fill="currentColor"
fillOpacity="0.055"
className="blur-[4px]"
/>
<IconStackLayer opacity="0.4" />
<IconStackLayer opacity="0.6" x={13.65} y={6.04} />
<IconStackLayer opacity="0.8" x={27.32} y={12.08} active />
</svg>
{children ? (
<div
data-slot="icon-stack-content"
className="text-muted-foreground pointer-events-none absolute top-[var(--icon-stack-content-y)] left-[var(--icon-stack-content-x)] flex -translate-x-1/2 -translate-y-1/2 scale-x-90 -skew-y-26 items-center justify-center"
>
{children}
</div>
) : null}
</div>
)
}
function IconStackLayer({
active = false,
opacity,
x = 0,
y = 0,
}: {
active?: boolean
opacity: string
x?: number
y?: number
}) {
return (
<g opacity={opacity} transform={`translate(${x} ${y})`}>
<path
data-slot="icon-stack-layer"
d="M42.2538 2.046C41.4408 1.6325 40.3965 1.6677 39.2612 2.2424L7.9616 18.1934C5.3895 19.5039 3.301 23.1064 3.301 26.2322V64.3226C3.301 66.0677 3.9458 67.2943 4.962 67.8199L1.8363 66.229C0.8201 65.7104 0.1753 64.4771 0.1753 62.732V24.6412C0.1753 21.5085 2.2638 17.913 4.8359 16.6024L36.1355 0.6515C37.2778 0.0698 38.322 0.0416 39.128 0.4551L42.2538 2.046Z"
stroke="currentColor"
strokeOpacity={active ? "0.3" : "0.2"}
strokeWidth="0.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
data-slot="icon-stack-layer"
d="M42.2545 2.0456C43.2707 2.5643 43.9155 3.7979 43.9155 5.543V43.6337C43.9155 46.7665 41.827 50.3616 39.2549 51.6722L7.9554 67.6235C6.813 68.2052 5.7687 68.2331 4.9628 67.8196C3.9465 67.301 3.3018 66.0673 3.3018 64.3222V26.2318C3.3018 23.0991 5.3903 19.5036 7.9624 18.193L39.2619 2.2421C40.4043 1.6604 41.4486 1.6321 42.2545 2.0456Z"
stroke="currentColor"
strokeOpacity={active ? "0.3" : "0.2"}
strokeWidth="0.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</g>
)
}
export { IconStack, type IconStackProps }
+125
View File
@@ -0,0 +1,125 @@
import { mergeProps } from "@base-ui/react/merge-props"
import { useRender } from "@base-ui/react/use-render"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@authportal/ui/lib/utils"
/**
* CSS variable architecture:
*
* The root owns four variables so every part of the tile stays in proportion
* and stays overridable from a single `className`:
*
* --icon-tile-size tile width/height
* --icon-tile-icon-size glyph size applied to child svgs
* --icon-tile-radius corner radius (also drives the nested inner card)
* --icon-tile-inset gap between the outer ring and the inner card
*
* The `frame` and `soft` variants paint their inner card with an `::after`
* pseudo element instead of a wrapper node. `isolate` makes the root a stacking
* context, so the negative z-index pseudo paints above the root background but
* below the in-flow icon - no extra DOM, and `render` composition keeps working.
*
* Tone: `soft` and `solid` derive every fill and border from `currentColor`, so
* a single text color class (e.g. `text-success`) retints the whole tile. They
* default to `text-primary`; override it to recolor without touching internals.
*/
const iconTileVariants = cva(
[
"relative inline-flex shrink-0 items-center justify-center align-middle",
"size-(--icon-tile-size) rounded-(--icon-tile-radius)",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-(--icon-tile-icon-size)",
],
{
variants: {
variant: {
/** Plain bordered surface. The quiet default for list rows and toolbars. */
outline: "border border-border bg-background dark:bg-input/32",
/** Raised muted fill with a background-colored ring. Reads as a physical chip. */
elevated:
"border-2 border-background bg-muted text-accent-foreground shadow-[0_1px_3px_0_rgb(0_0_0/0.14)] dark:border",
/**
* Tinted double container: an opacity-filled outer ring with no border
* around a bordered inner card, all derived from `currentColor`. The
* quiet, colorful sibling of `frame`. Retint with a text color class.
*/
soft: [
"isolate p-(--icon-tile-inset) text-primary bg-current/10",
"after:absolute after:-z-10 after:inset-(--icon-tile-inset)",
"after:rounded-[calc(var(--icon-tile-radius)-var(--icon-tile-inset))]",
"after:border after:border-current/20 after:bg-current/5",
],
/** Filled tone with a contrasting glyph. Retint with `bg-*` + a text color. */
solid: "bg-primary text-primary-foreground",
/** Double container - a muted ring around an inset card, matching Frame. */
frame: [
"isolate border border-border bg-muted/50 p-(--icon-tile-inset)",
"after:absolute after:-z-10 after:inset-(--icon-tile-inset)",
"after:rounded-[calc(var(--icon-tile-radius)-var(--icon-tile-inset))]",
"after:border after:border-border after:bg-card after:shadow-xs",
],
},
size: {
xs: "[--icon-tile-size:--spacing(6)] [--icon-tile-icon-size:--spacing(3.5)] [--icon-tile-inset:--spacing(0.5)]",
sm: "[--icon-tile-size:--spacing(8)] [--icon-tile-icon-size:--spacing(4)] [--icon-tile-inset:--spacing(0.5)]",
default:
"[--icon-tile-size:--spacing(10)] [--icon-tile-icon-size:--spacing(4.5)] [--icon-tile-inset:--spacing(0.75)]",
lg: "[--icon-tile-size:--spacing(12)] [--icon-tile-icon-size:--spacing(5.5)] [--icon-tile-inset:--spacing(0.75)]",
xl: "[--icon-tile-size:--spacing(14)] [--icon-tile-icon-size:--spacing(7)] [--icon-tile-inset:--spacing(1)]",
},
/**
* `default`: active style radius. `full`: circular.
* The plain value is the fallback outside a style scope; the `style-*`
* tokens win by specificity inside one, and survive the registry
* transform as the single resolved value per generated style.
*
* Each style token is clamped to a fraction of the tile. A flat radius
* is a circle once it reaches half the box, so the soft styles used to
* render `xs` (24px) and `sm` (32px) as plain circles and swallow the
* `full` variant's meaning. Clamping keeps one corner ratio at every
* size instead, so a tile still reads as the same shape when it scales.
* Luma and Rhea take the gentler quarter ratio; Maia stays at a third.
*/
radius: {
default:
"[--icon-tile-radius:min(var(--radius-md),calc(var(--icon-tile-size)/3))] [--icon-tile-radius:min(var(--radius-md),calc(var(--icon-tile-size)/3))]",
full: "[--icon-tile-radius:calc(infinity*1px)]",
},
},
defaultVariants: {
variant: "outline",
size: "default",
radius: "default",
},
}
)
interface IconTileProps extends useRender.ComponentProps<"span"> {
variant?: VariantProps<typeof iconTileVariants>["variant"]
size?: VariantProps<typeof iconTileVariants>["size"]
radius?: VariantProps<typeof iconTileVariants>["radius"]
}
function IconTile({
className,
variant = "outline",
size = "default",
radius = "default",
render,
...props
}: IconTileProps) {
const defaultProps = {
"data-slot": "icon-tile",
"data-variant": variant,
"data-size": size,
className: cn(iconTileVariants({ variant, size, radius, className })),
}
return useRender({
defaultTagName: "span",
render,
props: mergeProps<"span">(defaultProps, props),
})
}
export { IconTile, iconTileVariants, type IconTileProps }
+44 -40
View File
@@ -1,17 +1,20 @@
import { createFileRoute, Link } from '@tanstack/react-router'
import { useQuery } from '@tanstack/react-query'
import { LayoutGridIcon } from 'lucide-react'
import {
CloudIcon,
GlobeIcon,
LayoutGridIcon,
ServerIcon,
ShieldIcon,
} from 'lucide-react'
import type { AppAuthMode, AppId } from '@authportal/shared'
import { PageShell } from '@/components/page-shell'
import { Badge } from '@/components/reui/badge'
import { IconTile } from '@/components/reui/icon-tile'
import {
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { QuickActionGrid } from '@/components/reui-kit/quick-action-grid'
import { Button } from '@authportal/ui/components/button'
import { Skeleton } from '@authportal/ui/components/skeleton'
import { ssoOpenApp } from '@/lib/auth'
@@ -21,6 +24,14 @@ export const Route = createFileRoute('/_auth/apps')({
component: AppsPage,
})
const APP_ICONS: Record<AppId, typeof CloudIcon> = {
cfdm: CloudIcon,
vps: ServerIcon,
bgp: GlobeIcon,
fw: ShieldIcon,
dns: GlobeIcon,
}
async function openApp(
_appId: AppId,
baseUrl: string,
@@ -66,15 +77,19 @@ function AppsPage() {
{isLoading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-40 w-full rounded-xl" />
<Skeleton key={i} className="h-24 w-full rounded-xl" />
))}
</div>
) : apps.length === 0 ? (
<Frame dense className="w-full">
<FramePanel className="flex flex-col items-start gap-3 py-10">
<div className="bg-muted flex size-10 items-center justify-center rounded-lg">
<LayoutGridIcon className="text-muted-foreground size-5" />
</div>
<IconTile
variant="elevated"
aria-hidden="true"
className="size-10.5 text-muted-foreground"
>
<LayoutGridIcon />
</IconTile>
<div className="flex flex-col gap-1">
<p className="font-medium">Нет доступных приложений</p>
<p className="text-muted-foreground text-sm">
@@ -84,36 +99,25 @@ function AppsPage() {
</FramePanel>
</Frame>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{apps.map((app) => (
<Frame key={app.id} dense className="h-full">
<FrameHeader className="flex-row items-start justify-between gap-2">
<div className="flex flex-col gap-px">
<FrameTitle>{app.title}</FrameTitle>
<FrameDescription>{app.description}</FrameDescription>
</div>
<Badge variant="info-light" size="sm">
{app.id}
</Badge>
</FrameHeader>
<FrameFooter>
<Button
className="w-full"
onClick={() =>
void openApp(
app.id,
app.url,
app.authMode ??
(app.id === 'dns' ? 'oidc' : 'jwt'),
)
}
>
Открыть
</Button>
</FrameFooter>
</Frame>
))}
</div>
<QuickActionGrid
title=""
actions={apps.map((app) => {
const Icon = APP_ICONS[app.id] ?? LayoutGridIcon
return {
id: app.id,
title: app.title,
description: app.description,
badgeLabel: 'Открыть',
icon: <Icon />,
onSelect: () =>
void openApp(
app.id,
app.url,
app.authMode ?? (app.id === 'dns' ? 'oidc' : 'jwt'),
),
}
})}
/>
)}
</PageShell>
)
+4 -4
View File
@@ -5,7 +5,7 @@ Surface: **ReUI Frame**. Иерархия: **ReUI PRO > shadcn primitives**.
Карта: [docs](https://reui.io/docs) · [llms.txt](https://reui.io/llms.txt) · [Get Started](https://reui.io/docs/get-started) · [Styling](https://reui.io/docs/styling) · [Registry](https://reui.io/docs/registry) · [License](https://reui.io/docs/license-setup) · [Blocks](https://reui.io/blocks) · [MCP](https://reui.io/docs/mcp) · [Agent Skills](https://reui.io/docs/agent-skills)
Эталоны: [app-shell-12](https://reui.io/preview/base/app-shell-12) · [solution-users-1](https://reui.io/preview/base/solution-users-1) · [solution-users-6](https://reui.io/preview/base/solution-users-6) · [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) · [empty-state-12](https://reui.io/preview/base/empty-state-12) · KPI [stats-12](https://reui.io/preview/base/stats-12)
Эталоны: [app-shell-12](https://reui.io/preview/base/app-shell-12) · [solution-users-1](https://reui.io/preview/base/solution-users-1) · [solution-users-6](https://reui.io/preview/base/solution-users-6) · [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2) · [empty-state-14](https://reui.io/preview/base/empty-state-14) · [empty-state-12](https://reui.io/preview/base/empty-state-12) · KPI [stats-12](https://reui.io/preview/base/stats-12)
## Surface
@@ -15,7 +15,7 @@ Project lock: **`surface: frame`**. Ops / list / settings — только Frame
| Component | Role |
|-----------|------|
| `ResourcePage` | Frame + Filters + DataGrid (admin lists) |
| `AdminUsersGrid` | Frame + Filters + DataGrid (admin users, solution-users-1) |
| `KpiStatGrid` | EvoBGP hybrid KPI (если нужен dashboard) |
| `QuickActionGrid` | Frame quick actions (sibling KPI) |
| `SettingsShell` | settings nav |
@@ -32,7 +32,7 @@ Markup KPI/QuickActions — SoT EvoBGP (diff только `@authportal/ui`).
| Sidebar / hover | theme `--sidebar` / `--sidebar-accent` — без `color-mix` override |
| Header | `h-12`, sticky, `border-b`, `px-4 md:px-6` |
| Header left | `SidebarTrigger` + `Separator` + Breadcrumb |
| Header right | **AppsMenu** (тема — в NavUser) |
| Header right | **AppsMenu → SystemMonitorPopover** (тема — в NavUser) |
| Sidebar footer | **NavUser** — аватар, имя, email, тема (segmented), выход · [app-shell-1](https://reui.io/preview/base/app-shell-1) |
| `main` | `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5` |
@@ -43,7 +43,7 @@ Nav groups Auth Portal:
- **Портал:** Приложения (`/apps`)
- **Админ** (только `is_admin`): Пользователи (`/admin`), Журнал (`/admin/audit`), Ссылки приложений (`/admin/apps`)
App Switcher: `portal_settings.app_switcher_json` → public `GET /api/v1/app-switcher`, admin `GET/PUT /api/v1/admin/app-switcher`. Ids: `cfdm` · `vps` · `bgp` · `fw`.
App Switcher: `portal_settings.app_switcher_json` → public `GET /api/v1/app-switcher`, admin `GET/PUT /api/v1/admin/app-switcher`. Ids: `cfdm` · `vps` · `bgp` · `fw` · `dns`.
## MCP workflow
+2 -3
View File
@@ -68,9 +68,8 @@ function SheetContent({
/>
}
>
<XIcon
/>
<span className="sr-only">Close</span>
<XIcon aria-hidden="true" />
<span className="sr-only">Закрыть</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Popup>
+11 -8
View File
@@ -27,7 +27,7 @@ import { PanelLeftIcon } from "lucide-react"
const SIDEBAR_COOKIE_NAME = "sidebar_state"
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
const SIDEBAR_WIDTH = "16rem"
const SIDEBAR_WIDTH = "240px"
const SIDEBAR_WIDTH_MOBILE = "18rem"
const SIDEBAR_WIDTH_ICON = "3rem"
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
@@ -196,8 +196,8 @@ function Sidebar({
side={side}
>
<SheetHeader className="sr-only">
<SheetTitle>Sidebar</SheetTitle>
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
<SheetTitle>Боковое меню</SheetTitle>
<SheetDescription>Навигация по приложению</SheetDescription>
</SheetHeader>
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
@@ -256,7 +256,7 @@ function SidebarTrigger({
onClick,
...props
}: React.ComponentProps<typeof Button>) {
const { toggleSidebar } = useSidebar()
const { toggleSidebar, open, openMobile, isMobile } = useSidebar()
return (
<Button
@@ -264,6 +264,9 @@ function SidebarTrigger({
data-slot="sidebar-trigger"
variant="ghost"
size="icon-sm"
type="button"
aria-label="Боковое меню"
aria-expanded={isMobile ? openMobile : open}
className={cn(className)}
onClick={(event) => {
onClick?.(event)
@@ -271,8 +274,8 @@ function SidebarTrigger({
}}
{...props}
>
<PanelLeftIcon />
<span className="sr-only">Toggle Sidebar</span>
<PanelLeftIcon aria-hidden="true" />
<span className="sr-only">Боковое меню</span>
</Button>
)
}
@@ -284,10 +287,10 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
<button
data-sidebar="rail"
data-slot="sidebar-rail"
aria-label="Toggle Sidebar"
aria-label="Боковое меню"
tabIndex={-1}
onClick={toggleSidebar}
title="Toggle Sidebar"
title="Боковое меню"
className={cn(
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",