Merge branch 'claude/elated-pasteur-5c3133': новый дизайн фронтенда VPS-Tracker
Docker / build (push) Failing after 2m15s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Opus 5.5 committed 2026-11-06 22:47:56 +07:00
commit a185ffa659
24 files changed
+629 -487

No files matched your search

+2
View File
@@ -18,6 +18,8 @@
"@dnd-kit/modifiers": "^9.0.0", "@dnd-kit/modifiers": "^9.0.0",
"@dnd-kit/sortable": "^10.0.0", "@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2", "@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/geist": "^5.3.0",
"@fontsource-variable/geist-mono": "^5.3.0",
"@hookform/resolvers": "^3.10.0", "@hookform/resolvers": "^3.10.0",
"@tanstack/react-query": "^5.90.2", "@tanstack/react-query": "^5.90.2",
"@tanstack/react-query-devtools": "^5.90.2", "@tanstack/react-query-devtools": "^5.90.2",
+16
View File
@@ -2,6 +2,7 @@ import {
DropdownMenu, DropdownMenu,
DropdownMenuContent, DropdownMenuContent,
DropdownMenuItem, DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuShortcut, DropdownMenuShortcut,
DropdownMenuTrigger, DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu' } from '@cfdm/ui/components/dropdown-menu'
@@ -11,8 +12,10 @@ import {
SidebarMenuItem, SidebarMenuItem,
useSidebar, useSidebar,
} from '@cfdm/ui/components/sidebar' } from '@cfdm/ui/components/sidebar'
import { Link } from '@tanstack/react-router'
import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react' import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'
import { authPortalUrl, isAuthEnabled } from '@/lib/auth'
import { import {
APP_SWITCHER_ICONS, APP_SWITCHER_ICONS,
CURRENT_APP_ID, CURRENT_APP_ID,
@@ -88,6 +91,19 @@ export function AppSwitcher() {
</DropdownMenuItem> </DropdownMenuItem>
) )
})} })}
<DropdownMenuSeparator />
{isAuthEnabled() ? (
<DropdownMenuItem
nativeButton={false}
render={<a href={`${authPortalUrl().replace(/\/$/, '')}/admin/apps`} />}
>
Настроить на портале
</DropdownMenuItem>
) : (
<DropdownMenuItem nativeButton={false} render={<Link to="/settings/integrations" />}>
Интеграции
</DropdownMenuItem>
)}
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
</SidebarMenuItem> </SidebarMenuItem>
@@ -17,7 +17,7 @@ interface CountedLineTabsProps {
children?: ReactNode children?: ReactNode
} }
/** Line tabs with count pills (c-tabs-2 / data-grid-filtering-2). */ /** Page line tabs with mono counts (design «VPS-Tracker» ptabs; c-tabs-2). */
export function CountedLineTabs({ export function CountedLineTabs({
tabs, tabs,
value, value,
@@ -37,7 +37,7 @@ export function CountedLineTabs({
> >
<span>{tab.label}</span> <span>{tab.label}</span>
{tab.count !== undefined ? ( {tab.count !== undefined ? (
<span className="bg-muted text-muted-foreground inline-flex min-w-5 items-center justify-center rounded-md px-1.5 py-0.5 text-xs tabular-nums"> <span className="text-muted-foreground font-mono text-xs tabular-nums">
{tab.count} {tab.count}
</span> </span>
) : null} ) : null}
+94 -46
View File
@@ -16,6 +16,7 @@ import {
Network, Network,
ShieldAlert, ShieldAlert,
Globe, Globe,
ChevronDownIcon,
} from 'lucide-react' } from 'lucide-react'
import { import {
@@ -32,7 +33,13 @@ import {
SidebarMenuItem, SidebarMenuItem,
SidebarProvider, SidebarProvider,
SidebarTrigger, SidebarTrigger,
useSidebar,
} from '@cfdm/ui/components/sidebar' } from '@cfdm/ui/components/sidebar'
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger,
} from '@cfdm/ui/components/collapsible'
import { import {
Breadcrumb, Breadcrumb,
BreadcrumbItem, BreadcrumbItem,
@@ -41,8 +48,6 @@ import {
BreadcrumbPage, BreadcrumbPage,
BreadcrumbSeparator, BreadcrumbSeparator,
} from '@cfdm/ui/components/breadcrumb' } from '@cfdm/ui/components/breadcrumb'
import { Separator } from '@cfdm/ui/components/separator'
import { Badge } from '@cfdm/ui/components/badge'
import { TooltipProvider } from '@cfdm/ui/components/tooltip' import { TooltipProvider } from '@cfdm/ui/components/tooltip'
import { Link, useRouterState } from '@tanstack/react-router' import { Link, useRouterState } from '@tanstack/react-router'
@@ -50,7 +55,6 @@ import { useQuery } from '@tanstack/react-query'
import { useState, type CSSProperties, type ReactNode } from 'react' import { useState, type CSSProperties, type ReactNode } from 'react'
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover' import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
import { AppsMenu } from '@/components/layout/apps-menu'
import { SpaceSwitcher } from '@/components/layout/space-switcher' import { SpaceSwitcher } from '@/components/layout/space-switcher'
import { NavUser } from '@/components/layout/nav-user' import { NavUser } from '@/components/layout/nav-user'
import { AppSwitcher } from '@/components/app-switcher' import { AppSwitcher } from '@/components/app-switcher'
@@ -61,13 +65,16 @@ import {
permissionForPath, permissionForPath,
} from '@/lib/auth' } from '@/lib/auth'
import { SpaceProvider, useSpaceId } from '@/lib/space' import { SpaceProvider, useSpaceId } from '@/lib/space'
import { PageCrumbContext } from '@/lib/page-crumb'
import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface' import { SKIP_TO_CONTENT_CLASS } from '@/lib/ui-surface'
import { cn } from '@cfdm/ui/lib/utils'
interface NavItem { interface NavItem {
to: string to: string
label: string label: string
icon: typeof LayoutDashboard icon: typeof LayoutDashboard
badge?: number /** Problem dot (design `pdot`): destructive / warning. */
dot?: { tone: 'destructive' | 'warning'; label: string }
} }
interface NavGroup { interface NavGroup {
@@ -150,7 +157,10 @@ export function AppShell({ children }: { children: ReactNode }) {
const activeItem = const activeItem =
ALL_NAV_ITEMS.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`)) ?? ALL_NAV_ITEMS.find((i) => pathname === i.to || pathname.startsWith(`${i.to}/`)) ??
ALL_NAV_ITEMS[0] ALL_NAV_ITEMS[0]
const parentTo = PARENT_ROUTE[activeItem.to] const [pageCrumb, setPageCrumb] = useState<string | null>(null)
// Detail routes (design: «VPS / 203.0.113.21»): section links back, page shows the record title.
const isDetail = pageCrumb != null && pathname !== activeItem.to
const parentTo = isDetail ? activeItem.to : PARENT_ROUTE[activeItem.to]
const parentLabel = parentTo ? ROUTE_LABELS[parentTo] : null const parentLabel = parentTo ? ROUTE_LABELS[parentTo] : null
const { data: stats } = useQuery(dashboardStatsQueryOptions()) const { data: stats } = useQuery(dashboardStatsQueryOptions())
@@ -162,9 +172,21 @@ export function AppShell({ children }: { children: ReactNode }) {
const perm = permissionForPath(item.to) const perm = permissionForPath(item.to)
return !perm || can(perm) return !perm || can(perm)
}) })
.map((item) => { .map((item): NavItem => {
if (item.to === '/dashboard' && stats?.issuesCount) { if (item.to === '/dashboard' && stats?.issuesCount) {
return { ...item, badge: stats.issuesCount } return {
...item,
dot: { tone: 'destructive', label: `Проблем в инвентаре: ${stats.issuesCount}` },
}
}
if (item.to === '/accounts' && stats?.lowBalanceAccountCount) {
return {
...item,
dot: {
tone: 'warning',
label: `Низкий баланс: ${stats.lowBalanceAccountCount}`,
},
}
} }
return item return item
}), }),
@@ -190,38 +212,7 @@ export function AppShell({ children }: { children: ReactNode }) {
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
{navGroups.map((group) => ( {navGroups.map((group) => (
<SidebarGroup key={group.label}> <NavGroupSection key={group.label} group={group} pathname={pathname} />
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{group.items.map((item) => {
const Icon = item.icon
const isActive =
pathname === item.to || pathname.startsWith(`${item.to}/`)
return (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
render={<Link to={item.to} />}
isActive={isActive}
tooltip={item.label}
>
<Icon className="size-4" />
<span>{item.label}</span>
{item.badge ? (
<Badge
variant="destructive"
className="ml-auto size-5 justify-center p-0 text-xs"
>
{item.badge}
</Badge>
) : null}
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
))} ))}
</SidebarContent> </SidebarContent>
<SidebarFooter> <SidebarFooter>
@@ -231,10 +222,6 @@ export function AppShell({ children }: { children: ReactNode }) {
<SidebarInset id="main-content"> <SidebarInset id="main-content">
<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"> <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" /> <SidebarTrigger className="-ml-1" />
<Separator
orientation="vertical"
className="mr-2 data-[orientation=vertical]:h-4"
/>
<Breadcrumb> <Breadcrumb>
<BreadcrumbList> <BreadcrumbList>
{parentLabel && parentTo ? ( {parentLabel && parentTo ? (
@@ -249,18 +236,19 @@ export function AppShell({ children }: { children: ReactNode }) {
) : null} ) : null}
<BreadcrumbItem> <BreadcrumbItem>
<BreadcrumbPage> <BreadcrumbPage>
{ROUTE_LABELS[activeItem.to] ?? ''} {isDetail ? pageCrumb : (ROUTE_LABELS[activeItem.to] ?? '')}
</BreadcrumbPage> </BreadcrumbPage>
</BreadcrumbItem> </BreadcrumbItem>
</BreadcrumbList> </BreadcrumbList>
</Breadcrumb> </Breadcrumb>
<div className="ml-auto flex items-center gap-2"> <div className="ml-auto flex items-center gap-2">
<AppsMenu />
<SystemMonitorPopover /> <SystemMonitorPopover />
</div> </div>
</header> </header>
<div className="flex min-w-0 flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5"> <div className="flex min-w-0 flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
<SpaceScopedMain>{children}</SpaceScopedMain> <PageCrumbContext.Provider value={setPageCrumb}>
<SpaceScopedMain>{children}</SpaceScopedMain>
</PageCrumbContext.Provider>
</div> </div>
</SidebarInset> </SidebarInset>
<GlobalSearch open={searchOpen} onOpenChange={setSearchOpen} /> <GlobalSearch open={searchOpen} onOpenChange={setSearchOpen} />
@@ -270,6 +258,66 @@ export function AppShell({ children }: { children: ReactNode }) {
) )
} }
/** Collapsible nav group (design `grph`): uppercase label + chevron; always open in icon rail. */
function NavGroupSection({ group, pathname }: { group: NavGroup; pathname: string }) {
const { state, isMobile } = useSidebar()
const [open, setOpen] = useState(true)
const iconRail = state === 'collapsed' && !isMobile
return (
<Collapsible open={open || iconRail} onOpenChange={setOpen}>
<SidebarGroup>
<SidebarGroupLabel
render={<CollapsibleTrigger tabIndex={iconRail ? -1 : undefined} />}
className="hover:text-sidebar-foreground cursor-pointer uppercase tracking-wider transition-colors"
>
{group.label}
<ChevronDownIcon
aria-hidden
className={cn(
'ml-auto transition-transform motion-reduce:transition-none',
!open && '-rotate-90',
)}
/>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
<SidebarMenu>
{group.items.map((item) => {
const Icon = item.icon
const isActive = pathname === item.to || pathname.startsWith(`${item.to}/`)
return (
<SidebarMenuItem key={item.to}>
<SidebarMenuButton
render={<Link to={item.to} />}
isActive={isActive}
tooltip={item.label}
>
<Icon className="size-4" />
<span>{item.label}</span>
{item.dot ? (
<span
role="img"
aria-label={item.dot.label}
className={cn(
'ml-auto size-2 shrink-0 rounded-full',
'group-data-[collapsible=icon]:absolute group-data-[collapsible=icon]:top-1 group-data-[collapsible=icon]:right-1',
item.dot.tone === 'destructive' ? 'bg-destructive' : 'bg-warning',
)}
/>
) : null}
</SidebarMenuButton>
</SidebarMenuItem>
)
})}
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
)
}
/** Remount route content when space changes so snapshot queries pick new X-Space-Id. */ /** Remount route content when space changes so snapshot queries pick new X-Space-Id. */
function SpaceScopedMain({ children }: { children: ReactNode }) { function SpaceScopedMain({ children }: { children: ReactNode }) {
const { spaceId } = useSpaceId() const { spaceId } = useSpaceId()
@@ -1,105 +0,0 @@
import { Link } from '@tanstack/react-router'
import { LayoutGridIcon } from 'lucide-react'
import { Button } from '@cfdm/ui/components/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@cfdm/ui/components/dropdown-menu'
import { authPortalUrl, isAuthEnabled } from '@/lib/auth'
import {
APP_SWITCHER_ICONS,
CURRENT_APP_ID,
} from '@/lib/app-switcher-config'
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
/** Header apps grid — app-shell-12 AppsMenu, wired to auth-portal App Switcher. */
export function AppsMenu() {
const { config, isLoading } = useAppSwitcherConfig()
const portalAppsUrl = `${authPortalUrl().replace(/\/$/, '')}/admin/apps`
return (
<DropdownMenu>
<DropdownMenuTrigger
render={
<Button variant="ghost" size="icon" aria-label="Приложения" />
}
>
<LayoutGridIcon
className="size-4.5 transition-colors"
aria-hidden="true"
/>
</DropdownMenuTrigger>
<DropdownMenuContent
side="bottom"
align="end"
sideOffset={8}
className="w-72"
>
<DropdownMenuGroup>
<DropdownMenuLabel>
{isLoading ? 'Загрузка…' : config.menuLabel}
</DropdownMenuLabel>
<div className="grid grid-cols-3 gap-1 p-1">
{config.apps.map((app) => {
const Icon = APP_SWITCHER_ICONS[app.icon] ?? APP_SWITCHER_ICONS.server
const isCurrent = app.id === CURRENT_APP_ID
if (isCurrent) {
return (
<DropdownMenuItem
key={app.id}
disabled
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
>
<span className="text-muted-foreground">
<Icon aria-hidden="true" />
</span>
<span className="text-xs font-medium">{app.name}</span>
</DropdownMenuItem>
)
}
return (
<DropdownMenuItem
key={app.id}
nativeButton={false}
render={<a href={app.url} />}
className="h-auto flex-col gap-1.5 py-3 text-center [&_svg]:size-5"
>
<span className="text-muted-foreground">
<Icon aria-hidden="true" />
</span>
<span className="text-xs font-medium">{app.name}</span>
</DropdownMenuItem>
)
})}
</div>
<DropdownMenuSeparator />
{isAuthEnabled() ? (
<DropdownMenuItem
nativeButton={false}
render={<a href={portalAppsUrl} />}
className="justify-center text-sm font-medium"
>
Настроить на портале
</DropdownMenuItem>
) : (
<DropdownMenuItem
nativeButton={false}
render={<Link to="/settings/integrations" />}
className="justify-center text-sm font-medium"
>
Интеграции
</DropdownMenuItem>
)}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}
+5 -5
View File
@@ -141,12 +141,12 @@ export function NavUser() {
render={ render={
<SidebarMenuButton <SidebarMenuButton
size="lg" size="lg"
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground" className="bg-muted/50 data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
/> />
} }
> >
<Avatar className="size-8 rounded-lg"> <Avatar className="size-8 rounded-full border">
<AvatarFallback className="rounded-lg text-xs"> <AvatarFallback className="bg-background text-xs">
{fallback} {fallback}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
@@ -166,8 +166,8 @@ export function NavUser() {
> >
<DropdownMenuGroup> <DropdownMenuGroup>
<DropdownMenuLabel className="flex items-center gap-2 py-2 font-normal text-foreground"> <DropdownMenuLabel className="flex items-center gap-2 py-2 font-normal text-foreground">
<Avatar className="size-8 rounded-lg"> <Avatar className="size-8 rounded-full border">
<AvatarFallback className="rounded-lg text-xs"> <AvatarFallback className="bg-background text-xs">
{fallback} {fallback}
</AvatarFallback> </AvatarFallback>
</Avatar> </Avatar>
@@ -3,12 +3,16 @@ import { useQuery } from '@tanstack/react-query'
import { import {
Activity, Activity,
Bell, Bell,
ChevronRight,
RefreshCw, RefreshCw,
Server, Server,
Wallet, Wallet,
} from 'lucide-react' } from 'lucide-react'
import { Link } from '@tanstack/react-router'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { Button } from '@cfdm/ui/components/button'
import { cn } from '@cfdm/ui/lib/utils' import { cn } from '@cfdm/ui/lib/utils'
import { Item, ItemMedia } from '@cfdm/ui/components/item' import { Item, ItemMedia } from '@cfdm/ui/components/item'
import { Popover, PopoverContent, PopoverTrigger } from '@cfdm/ui/components/popover' import { Popover, PopoverContent, PopoverTrigger } from '@cfdm/ui/components/popover'
@@ -214,42 +218,43 @@ export function SystemMonitorPopover() {
], ],
) )
const spiking = metrics.some((m) => m.alert) || !apiOk const errorCount = metrics.filter((m) => m.alert && m.tone === 'destructive').length + (apiOk ? 0 : 1)
const warningCount = metrics.filter((m) => m.alert && m.tone === 'warning').length
const spiking = errorCount + warningCount > 0
const statusLabel = spiking ? 'Внимание' : 'Норма'
return ( return (
<Popover> <Popover>
<PopoverTrigger <PopoverTrigger
render={ render={
<button <Button
type="button" variant="outline"
aria-label="Монитор системы" aria-label={`Монитор VPS Tracker: ${statusLabel.toLowerCase()}, ошибок ${errorCount}, предупреждений ${warningCount}`}
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
<Activity aria-hidden
aria-hidden className={cn(
className={cn( errorCount > 0 ? 'text-destructive' : warningCount > 0 ? 'text-warning' : 'text-muted-foreground',
'size-3.5 transition-colors', )}
spiking ? 'text-destructive' : 'text-muted-foreground', />
)} <span className="hidden sm:inline">{statusLabel}</span>
/> {errorCount > 0 ? (
{spiking ? ( <Badge variant="destructive-light" size="sm" className="tabular-nums">
<span className="bg-destructive/25 absolute inset-0 animate-ping rounded-full" aria-hidden /> {errorCount}
) : null} </Badge>
</span> ) : null}
<span className="text-foreground hidden text-xs font-medium sm:inline">Система</span> {warningCount > 0 ? (
<Badge <Badge variant="warning-light" size="sm" className="tabular-nums">
variant={spiking ? 'destructive-light' : 'success-light'} {warningCount}
size="xs" </Badge>
className="h-4 px-1.5 text-[10px]" ) : null}
> {!spiking ? (
{spiking ? 'Внимание' : 'Норма'} <Badge variant="success-light" size="sm">
</Badge> OK
</Badge>
) : null}
</PopoverTrigger> </PopoverTrigger>
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!"> <PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
@@ -290,6 +295,13 @@ export function SystemMonitorPopover() {
</> </>
) : null} ) : null}
</div> </div>
<Link
to="/sync-journal"
className="border-border text-muted-foreground hover:text-foreground flex items-center justify-between border-t px-3 py-2 text-xs transition-colors"
>
Журнал синка
<ChevronRight aria-hidden className="size-3.5" />
</Link>
</PopoverContent> </PopoverContent>
</Popover> </Popover>
) )
+4 -4
View File
@@ -6,12 +6,12 @@ interface PageHeaderProps {
actions?: ReactNode actions?: ReactNode
} }
/** Page-level section header — etalon EvoBGP. */ /** Page header — design «VPS-Tracker»: 20px title + muted sub, actions bottom-aligned right. */
export function PageHeader({ title, description, actions }: PageHeaderProps) { export function PageHeader({ title, description, actions }: PageHeaderProps) {
return ( return (
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between"> <div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-0.5">
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1> <h1 className="text-xl font-semibold tracking-tight">{title}</h1>
{description ? ( {description ? (
<p className="text-muted-foreground text-sm">{description}</p> <p className="text-muted-foreground text-sm">{description}</p>
) : null} ) : null}
+3 -4
View File
@@ -3,8 +3,7 @@ export function kpiCols(count: number): string {
if (count <= 1) return 'grid-cols-1' if (count <= 1) return 'grid-cols-1'
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2' if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
if (count === 3) return 'grid-cols-1 @3xl:grid-cols-3' 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 @3xl:grid-cols-2 @5xl:grid-cols-4'
if (count === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5' // Design «VPS-Tracker»: at most 4 KPI columns, wrapping to the next row.
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6' return 'grid-cols-1 @xl:grid-cols-2 @5xl:grid-cols-4'
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
} }
@@ -44,7 +44,8 @@ const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = { const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
default: 'text-foreground', default: 'text-foreground',
warning: 'text-warning', // text-warning on white fails contrast; readable *-foreground in light theme.
warning: 'text-warning-foreground dark:text-warning',
destructive: 'text-destructive', destructive: 'text-destructive',
} }
@@ -63,11 +64,17 @@ function isSelected(item: KpiStatItem): boolean {
return Boolean(item.selected ?? item.active) return Boolean(item.selected ?? item.active)
} }
const HINT_BADGE_VARIANT = {
default: 'outline',
warning: 'warning-light',
destructive: 'destructive-light',
} as const
function resolveFooter(item: KpiStatItem): ReactNode { function resolveFooter(item: KpiStatItem): ReactNode {
if (item.footer) return item.footer if (item.footer) return item.footer
if (typeof item.hint === 'string') { if (typeof item.hint === 'string') {
return ( return (
<Badge variant="outline" size="sm"> <Badge variant={HINT_BADGE_VARIANT[item.variant ?? 'default']} size="sm">
{item.hint} {item.hint}
</Badge> </Badge>
) )
@@ -81,7 +88,7 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
const valueVariant = item.variant ?? 'default' const valueVariant = item.variant ?? 'default'
return ( return (
<div className="relative z-10 flex h-full items-start gap-3"> <div className="relative z-10 flex h-full items-center gap-3">
{item.icon ? ( {item.icon ? (
<IconTile <IconTile
variant="elevated" variant="elevated"
@@ -92,18 +99,19 @@ function KpiStatCardBody({ item }: { item: KpiStatItem }) {
</IconTile> </IconTile>
) : null} ) : null}
<div className="flex min-w-0 flex-1 flex-col gap-0.5"> {/* Design «VPS-Tracker» KPI: label, then value + hint Badge on one baseline. */}
<div className="flex items-start justify-between gap-2"> <div className="flex min-w-0 flex-1 flex-col gap-1">
<div className="text-muted-foreground text-sm font-medium">{item.label}</div> <div className="text-muted-foreground truncate text-sm font-medium">{item.label}</div>
{footer ? <div className="shrink-0">{footer}</div> : null} <div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
</div> <span
<div className={cn(
className={cn( 'text-2xl leading-none font-semibold tracking-tight tabular-nums',
'text-2xl leading-none font-bold tabular-nums', VALUE_VARIANT_CLASS[valueVariant],
VALUE_VARIANT_CLASS[valueVariant], )}
)} >
> {item.value}
{item.value} </span>
{footer ? <span className="shrink-0">{footer}</span> : null}
</div> </div>
</div> </div>
</div> </div>
@@ -1,11 +1,4 @@
import type { ReactNode } from 'react' import type { ReactNode } from 'react'
import {
Frame,
FrameDescription,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
import { Skeleton } from '@cfdm/ui/components/skeleton' import { Skeleton } from '@cfdm/ui/components/skeleton'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { KpiStatGrid, type KpiStatCard } from './kpi-stat-grid' import { KpiStatGrid, type KpiStatCard } from './kpi-stat-grid'
@@ -16,13 +9,15 @@ interface OpsDashboardProps {
title?: string title?: string
description?: string description?: string
kpiCards: OpsKpiCard[] kpiCards: OpsKpiCard[]
charts: ReactNode /** Alert between the page header and KPI (design order: title → banner → KPI → tabs). */
queue: ReactNode banner?: ReactNode
/** Page body after KPI — design «VPS-Tracker»: line tabs (Проблемы / Последние VPS / Аккаунты). */
children: ReactNode
isLoading?: boolean isLoading?: boolean
} }
const rootClassName = const rootClassName =
'text-foreground @container flex w-full flex-col gap-2 md:gap-3' 'text-foreground @container flex w-full flex-col gap-4 md:gap-6'
function OpsDashboardSkeleton() { function OpsDashboardSkeleton() {
return ( return (
@@ -45,8 +40,8 @@ export function OpsDashboard({
title = 'Панель управления', title = 'Панель управления',
description = 'Обзор VPS, платежей, тарифов и синхронизации', description = 'Обзор VPS, платежей, тарифов и синхронизации',
kpiCards, kpiCards,
charts, banner,
queue, children,
isLoading = false, isLoading = false,
}: OpsDashboardProps) { }: OpsDashboardProps) {
if (isLoading) { if (isLoading) {
@@ -57,25 +52,13 @@ export function OpsDashboard({
<div className={rootClassName}> <div className={rootClassName}>
<PageHeader title={title} description={description} /> <PageHeader title={title} description={description} />
{banner}
<section aria-label="Ключевые метрики"> <section aria-label="Ключевые метрики">
<KpiStatGrid cards={kpiCards} skeletonCount={4} /> <KpiStatGrid cards={kpiCards} skeletonCount={4} />
</section> </section>
<section aria-label="Аналитика" className="grid w-full min-w-0 gap-2"> {children}
{charts}
</section>
<section aria-label="Требуют внимания">
<Frame dense spacing="sm" className="w-full">
<FrameHeader>
<FrameTitle>Требуют внимания</FrameTitle>
<FrameDescription>
Проблемы оплаты, истекающие VPS и ошибки синхронизации
</FrameDescription>
</FrameHeader>
<FramePanel>{queue}</FramePanel>
</Frame>
</section>
</div> </div>
) )
} }
@@ -7,11 +7,9 @@ import {
SettingsIcon, SettingsIcon,
} from 'lucide-react' } from 'lucide-react'
import { useIsMobile } from '@cfdm/ui/hooks/use-mobile'
import { cn } from '@cfdm/ui/lib/utils'
import { PageShell } from '@/components/page-shell' import { PageShell } from '@/components/page-shell'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { Separator } from '@cfdm/ui/components/separator' import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs'
export interface SettingsTabConfig { export interface SettingsTabConfig {
id: string id: string
@@ -55,69 +53,42 @@ interface SettingsShellProps {
tabs?: SettingsTabConfig[] tabs?: SettingsTabConfig[]
} }
/** Settings shell — preview https://reui.io/preview/base/settings-16 */ /** Settings shell — line tabs on top (design «VPS-Tracker»); sections: https://reui.io/preview/base/settings-16 */
export function SettingsShell({ export function SettingsShell({
title = 'Настройки', title = 'Настройки',
description = 'Параметры приложения и связи с другими сервисами', description = 'Параметры приложения и связи с другими сервисами',
tabs = DEFAULT_TABS, tabs = DEFAULT_TABS,
}: SettingsShellProps) { }: SettingsShellProps) {
const isMobile = useIsMobile()
const pathname = useRouterState({ select: (s) => s.location.pathname }) const pathname = useRouterState({ select: (s) => s.location.pathname })
const active =
tabs.find((tab) =>
tab.exact ? pathname === tab.to || pathname === `${tab.to}/` : pathname.startsWith(tab.to),
) ?? tabs[0]
return ( return (
<PageShell> <PageShell>
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5"> <PageHeader title={title} description={description} />
<div className="flex flex-col gap-3"> {/* Design «VPS-Tracker»: settings sections as page line tabs. */}
<PageHeader title={title} description={description} /> {tabs.length > 1 ? (
<Separator /> <Tabs value={active?.id}>
</div> <TabsList variant="line" aria-label="Разделы настроек" className="gap-5">
{tabs.map((tab) => (
<div <TabsTrigger
className={cn( key={tab.id}
'flex gap-5', value={tab.id}
isMobile ? 'flex-col' : 'flex-row items-start', nativeButton={false}
)} render={<Link to={tab.to} />}
> className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
{tabs.length > 1 ? ( >
<nav {tab.icon}
aria-label="Разделы настроек" {tab.label}
className={cn( </TabsTrigger>
'flex gap-1', ))}
isMobile </TabsList>
? 'scrollbar-none -mx-1 overflow-x-auto overflow-y-hidden pb-1' </Tabs>
: 'w-44 shrink-0 flex-col', ) : null}
)} <div className="min-w-0">
> <Outlet />
{tabs.map((tab) => {
const isActive = tab.exact
? pathname === tab.to || pathname === `${tab.to}/`
: pathname.startsWith(tab.to)
return (
<Link
key={tab.id}
to={tab.to}
aria-current={isActive ? 'page' : undefined}
className={cn(
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
isMobile && 'shrink-0',
!isMobile && 'w-full',
isActive
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
)}
>
{tab.icon}
{tab.label}
</Link>
)
})}
</nav>
) : null}
<div className="min-w-0 flex-1">
<Outlet />
</div>
</div>
</div> </div>
</PageShell> </PageShell>
) )
+13
View File
@@ -0,0 +1,13 @@
import { createContext, useContext, useEffect } from 'react'
/** Setter for the current page breadcrumb (detail routes: «VPS / 203.0.113.21»). */
export const PageCrumbContext = createContext<(title: string | null) => void>(() => {})
/** Detail pages call this; AppShell shows `section / title` while the page is mounted. */
export function usePageCrumb(title: string | null | undefined) {
const setTitle = useContext(PageCrumbContext)
useEffect(() => {
setTitle(title || null)
return () => setTitle(null)
}, [setTitle, title])
}
+1
View File
@@ -5,6 +5,7 @@ import { RouterProvider } from '@tanstack/react-router'
import { Toaster } from '@cfdm/ui/components/sonner' import { Toaster } from '@cfdm/ui/components/sonner'
import '@cfdm/ui/globals.css' import '@cfdm/ui/globals.css'
import '@/styles/theme.css'
import { queryClient } from '@/lib/queryClient' import { queryClient } from '@/lib/queryClient'
import { createRouter } from '@/lib/router' import { createRouter } from '@/lib/router'
+34 -53
View File
@@ -31,11 +31,11 @@ import { Route as AuthAuditRouteImport } from './routes/_auth/audit'
import { Route as AuthAccountsRouteImport } from './routes/_auth/accounts' import { Route as AuthAccountsRouteImport } from './routes/_auth/accounts'
import { Route as AuthSettingsRouteRouteImport } from './routes/_auth/settings/route' import { Route as AuthSettingsRouteRouteImport } from './routes/_auth/settings/route'
import { Route as AuthSettingsIndexRouteImport } from './routes/_auth/settings/index' import { Route as AuthSettingsIndexRouteImport } from './routes/_auth/settings/index'
import { Route as AuthVpsVpsIdRouteImport } from './routes/_auth/vps.$vpsId' import { Route as AuthVpsVpsIdRouteImport } from './routes/_auth/vps_.$vpsId'
import { Route as AuthSettingsSyncRouteImport } from './routes/_auth/settings/sync' import { Route as AuthSettingsSyncRouteImport } from './routes/_auth/settings/sync'
import { Route as AuthSettingsNotificationsRouteImport } from './routes/_auth/settings/notifications' import { Route as AuthSettingsNotificationsRouteImport } from './routes/_auth/settings/notifications'
import { Route as AuthSettingsIntegrationsRouteImport } from './routes/_auth/settings/integrations' import { Route as AuthSettingsIntegrationsRouteImport } from './routes/_auth/settings/integrations'
import { Route as AuthProjectsProjectIdRouteImport } from './routes/_auth/projects.$projectId' import { Route as AuthProjectsProjectIdRouteImport } from './routes/_auth/projects_.$projectId'
const AuthRoute = AuthRouteImport.update({ const AuthRoute = AuthRouteImport.update({
id: '/_auth', id: '/_auth',
@@ -147,9 +147,9 @@ const AuthSettingsIndexRoute = AuthSettingsIndexRouteImport.update({
getParentRoute: () => AuthSettingsRouteRoute, getParentRoute: () => AuthSettingsRouteRoute,
} as any) } as any)
const AuthVpsVpsIdRoute = AuthVpsVpsIdRouteImport.update({ const AuthVpsVpsIdRoute = AuthVpsVpsIdRouteImport.update({
id: '/$vpsId', id: '/vps_/$vpsId',
path: '/$vpsId', path: '/vps/$vpsId',
getParentRoute: () => AuthVpsRoute, getParentRoute: () => AuthRoute,
} as any) } as any)
const AuthSettingsSyncRoute = AuthSettingsSyncRouteImport.update({ const AuthSettingsSyncRoute = AuthSettingsSyncRouteImport.update({
id: '/sync', id: '/sync',
@@ -169,9 +169,9 @@ const AuthSettingsIntegrationsRoute =
getParentRoute: () => AuthSettingsRouteRoute, getParentRoute: () => AuthSettingsRouteRoute,
} as any) } as any)
const AuthProjectsProjectIdRoute = AuthProjectsProjectIdRouteImport.update({ const AuthProjectsProjectIdRoute = AuthProjectsProjectIdRouteImport.update({
id: '/$projectId', id: '/projects_/$projectId',
path: '/$projectId', path: '/projects/$projectId',
getParentRoute: () => AuthProjectsRoute, getParentRoute: () => AuthRoute,
} as any) } as any)
export interface FileRoutesByFullPath { export interface FileRoutesByFullPath {
@@ -184,7 +184,7 @@ export interface FileRoutesByFullPath {
'/dashboard': typeof AuthDashboardRoute '/dashboard': typeof AuthDashboardRoute
'/geo': typeof AuthGeoRoute '/geo': typeof AuthGeoRoute
'/payments': typeof AuthPaymentsRoute '/payments': typeof AuthPaymentsRoute
'/projects': typeof AuthProjectsRouteWithChildren '/projects': typeof AuthProjectsRoute
'/providers': typeof AuthProvidersRoute '/providers': typeof AuthProvidersRoute
'/renewals': typeof AuthRenewalsRoute '/renewals': typeof AuthRenewalsRoute
'/reports': typeof AuthReportsRoute '/reports': typeof AuthReportsRoute
@@ -193,7 +193,7 @@ export interface FileRoutesByFullPath {
'/sync-journal': typeof AuthSyncJournalRoute '/sync-journal': typeof AuthSyncJournalRoute
'/tariffs': typeof AuthTariffsRoute '/tariffs': typeof AuthTariffsRoute
'/topology': typeof AuthTopologyRoute '/topology': typeof AuthTopologyRoute
'/vps': typeof AuthVpsRouteWithChildren '/vps': typeof AuthVpsRoute
'/auth/callback': typeof AuthCallbackRoute '/auth/callback': typeof AuthCallbackRoute
'/projects/$projectId': typeof AuthProjectsProjectIdRoute '/projects/$projectId': typeof AuthProjectsProjectIdRoute
'/settings/integrations': typeof AuthSettingsIntegrationsRoute '/settings/integrations': typeof AuthSettingsIntegrationsRoute
@@ -211,7 +211,7 @@ export interface FileRoutesByTo {
'/dashboard': typeof AuthDashboardRoute '/dashboard': typeof AuthDashboardRoute
'/geo': typeof AuthGeoRoute '/geo': typeof AuthGeoRoute
'/payments': typeof AuthPaymentsRoute '/payments': typeof AuthPaymentsRoute
'/projects': typeof AuthProjectsRouteWithChildren '/projects': typeof AuthProjectsRoute
'/providers': typeof AuthProvidersRoute '/providers': typeof AuthProvidersRoute
'/renewals': typeof AuthRenewalsRoute '/renewals': typeof AuthRenewalsRoute
'/reports': typeof AuthReportsRoute '/reports': typeof AuthReportsRoute
@@ -220,7 +220,7 @@ export interface FileRoutesByTo {
'/sync-journal': typeof AuthSyncJournalRoute '/sync-journal': typeof AuthSyncJournalRoute
'/tariffs': typeof AuthTariffsRoute '/tariffs': typeof AuthTariffsRoute
'/topology': typeof AuthTopologyRoute '/topology': typeof AuthTopologyRoute
'/vps': typeof AuthVpsRouteWithChildren '/vps': typeof AuthVpsRoute
'/auth/callback': typeof AuthCallbackRoute '/auth/callback': typeof AuthCallbackRoute
'/projects/$projectId': typeof AuthProjectsProjectIdRoute '/projects/$projectId': typeof AuthProjectsProjectIdRoute
'/settings/integrations': typeof AuthSettingsIntegrationsRoute '/settings/integrations': typeof AuthSettingsIntegrationsRoute
@@ -241,7 +241,7 @@ export interface FileRoutesById {
'/_auth/dashboard': typeof AuthDashboardRoute '/_auth/dashboard': typeof AuthDashboardRoute
'/_auth/geo': typeof AuthGeoRoute '/_auth/geo': typeof AuthGeoRoute
'/_auth/payments': typeof AuthPaymentsRoute '/_auth/payments': typeof AuthPaymentsRoute
'/_auth/projects': typeof AuthProjectsRouteWithChildren '/_auth/projects': typeof AuthProjectsRoute
'/_auth/providers': typeof AuthProvidersRoute '/_auth/providers': typeof AuthProvidersRoute
'/_auth/renewals': typeof AuthRenewalsRoute '/_auth/renewals': typeof AuthRenewalsRoute
'/_auth/reports': typeof AuthReportsRoute '/_auth/reports': typeof AuthReportsRoute
@@ -250,13 +250,13 @@ export interface FileRoutesById {
'/_auth/sync-journal': typeof AuthSyncJournalRoute '/_auth/sync-journal': typeof AuthSyncJournalRoute
'/_auth/tariffs': typeof AuthTariffsRoute '/_auth/tariffs': typeof AuthTariffsRoute
'/_auth/topology': typeof AuthTopologyRoute '/_auth/topology': typeof AuthTopologyRoute
'/_auth/vps': typeof AuthVpsRouteWithChildren '/_auth/vps': typeof AuthVpsRoute
'/auth/callback': typeof AuthCallbackRoute '/auth/callback': typeof AuthCallbackRoute
'/_auth/projects/$projectId': typeof AuthProjectsProjectIdRoute '/_auth/projects_/$projectId': typeof AuthProjectsProjectIdRoute
'/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute '/_auth/settings/integrations': typeof AuthSettingsIntegrationsRoute
'/_auth/settings/notifications': typeof AuthSettingsNotificationsRoute '/_auth/settings/notifications': typeof AuthSettingsNotificationsRoute
'/_auth/settings/sync': typeof AuthSettingsSyncRoute '/_auth/settings/sync': typeof AuthSettingsSyncRoute
'/_auth/vps/$vpsId': typeof AuthVpsVpsIdRoute '/_auth/vps_/$vpsId': typeof AuthVpsVpsIdRoute
'/_auth/settings/': typeof AuthSettingsIndexRoute '/_auth/settings/': typeof AuthSettingsIndexRoute
} }
export interface FileRouteTypes { export interface FileRouteTypes {
@@ -338,11 +338,11 @@ export interface FileRouteTypes {
| '/_auth/topology' | '/_auth/topology'
| '/_auth/vps' | '/_auth/vps'
| '/auth/callback' | '/auth/callback'
| '/_auth/projects/$projectId' | '/_auth/projects_/$projectId'
| '/_auth/settings/integrations' | '/_auth/settings/integrations'
| '/_auth/settings/notifications' | '/_auth/settings/notifications'
| '/_auth/settings/sync' | '/_auth/settings/sync'
| '/_auth/vps/$vpsId' | '/_auth/vps_/$vpsId'
| '/_auth/settings/' | '/_auth/settings/'
fileRoutesById: FileRoutesById fileRoutesById: FileRoutesById
} }
@@ -508,12 +508,12 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthSettingsIndexRouteImport preLoaderRoute: typeof AuthSettingsIndexRouteImport
parentRoute: typeof AuthSettingsRouteRoute parentRoute: typeof AuthSettingsRouteRoute
} }
'/_auth/vps/$vpsId': { '/_auth/vps_/$vpsId': {
id: '/_auth/vps/$vpsId' id: '/_auth/vps_/$vpsId'
path: '/$vpsId' path: '/vps/$vpsId'
fullPath: '/vps/$vpsId' fullPath: '/vps/$vpsId'
preLoaderRoute: typeof AuthVpsVpsIdRouteImport preLoaderRoute: typeof AuthVpsVpsIdRouteImport
parentRoute: typeof AuthVpsRoute parentRoute: typeof AuthRoute
} }
'/_auth/settings/sync': { '/_auth/settings/sync': {
id: '/_auth/settings/sync' id: '/_auth/settings/sync'
@@ -536,12 +536,12 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthSettingsIntegrationsRouteImport preLoaderRoute: typeof AuthSettingsIntegrationsRouteImport
parentRoute: typeof AuthSettingsRouteRoute parentRoute: typeof AuthSettingsRouteRoute
} }
'/_auth/projects/$projectId': { '/_auth/projects_/$projectId': {
id: '/_auth/projects/$projectId' id: '/_auth/projects_/$projectId'
path: '/$projectId' path: '/projects/$projectId'
fullPath: '/projects/$projectId' fullPath: '/projects/$projectId'
preLoaderRoute: typeof AuthProjectsProjectIdRouteImport preLoaderRoute: typeof AuthProjectsProjectIdRouteImport
parentRoute: typeof AuthProjectsRoute parentRoute: typeof AuthRoute
} }
} }
} }
@@ -563,29 +563,6 @@ const AuthSettingsRouteRouteChildren: AuthSettingsRouteRouteChildren = {
const AuthSettingsRouteRouteWithChildren = const AuthSettingsRouteRouteWithChildren =
AuthSettingsRouteRoute._addFileChildren(AuthSettingsRouteRouteChildren) AuthSettingsRouteRoute._addFileChildren(AuthSettingsRouteRouteChildren)
interface AuthProjectsRouteChildren {
AuthProjectsProjectIdRoute: typeof AuthProjectsProjectIdRoute
}
const AuthProjectsRouteChildren: AuthProjectsRouteChildren = {
AuthProjectsProjectIdRoute: AuthProjectsProjectIdRoute,
}
const AuthProjectsRouteWithChildren = AuthProjectsRoute._addFileChildren(
AuthProjectsRouteChildren,
)
interface AuthVpsRouteChildren {
AuthVpsVpsIdRoute: typeof AuthVpsVpsIdRoute
}
const AuthVpsRouteChildren: AuthVpsRouteChildren = {
AuthVpsVpsIdRoute: AuthVpsVpsIdRoute,
}
const AuthVpsRouteWithChildren =
AuthVpsRoute._addFileChildren(AuthVpsRouteChildren)
interface AuthRouteChildren { interface AuthRouteChildren {
AuthSettingsRouteRoute: typeof AuthSettingsRouteRouteWithChildren AuthSettingsRouteRoute: typeof AuthSettingsRouteRouteWithChildren
AuthAccountsRoute: typeof AuthAccountsRoute AuthAccountsRoute: typeof AuthAccountsRoute
@@ -595,7 +572,7 @@ interface AuthRouteChildren {
AuthDashboardRoute: typeof AuthDashboardRoute AuthDashboardRoute: typeof AuthDashboardRoute
AuthGeoRoute: typeof AuthGeoRoute AuthGeoRoute: typeof AuthGeoRoute
AuthPaymentsRoute: typeof AuthPaymentsRoute AuthPaymentsRoute: typeof AuthPaymentsRoute
AuthProjectsRoute: typeof AuthProjectsRouteWithChildren AuthProjectsRoute: typeof AuthProjectsRoute
AuthProvidersRoute: typeof AuthProvidersRoute AuthProvidersRoute: typeof AuthProvidersRoute
AuthRenewalsRoute: typeof AuthRenewalsRoute AuthRenewalsRoute: typeof AuthRenewalsRoute
AuthReportsRoute: typeof AuthReportsRoute AuthReportsRoute: typeof AuthReportsRoute
@@ -604,7 +581,9 @@ interface AuthRouteChildren {
AuthSyncJournalRoute: typeof AuthSyncJournalRoute AuthSyncJournalRoute: typeof AuthSyncJournalRoute
AuthTariffsRoute: typeof AuthTariffsRoute AuthTariffsRoute: typeof AuthTariffsRoute
AuthTopologyRoute: typeof AuthTopologyRoute AuthTopologyRoute: typeof AuthTopologyRoute
AuthVpsRoute: typeof AuthVpsRouteWithChildren AuthVpsRoute: typeof AuthVpsRoute
AuthProjectsProjectIdRoute: typeof AuthProjectsProjectIdRoute
AuthVpsVpsIdRoute: typeof AuthVpsVpsIdRoute
} }
const AuthRouteChildren: AuthRouteChildren = { const AuthRouteChildren: AuthRouteChildren = {
@@ -616,7 +595,7 @@ const AuthRouteChildren: AuthRouteChildren = {
AuthDashboardRoute: AuthDashboardRoute, AuthDashboardRoute: AuthDashboardRoute,
AuthGeoRoute: AuthGeoRoute, AuthGeoRoute: AuthGeoRoute,
AuthPaymentsRoute: AuthPaymentsRoute, AuthPaymentsRoute: AuthPaymentsRoute,
AuthProjectsRoute: AuthProjectsRouteWithChildren, AuthProjectsRoute: AuthProjectsRoute,
AuthProvidersRoute: AuthProvidersRoute, AuthProvidersRoute: AuthProvidersRoute,
AuthRenewalsRoute: AuthRenewalsRoute, AuthRenewalsRoute: AuthRenewalsRoute,
AuthReportsRoute: AuthReportsRoute, AuthReportsRoute: AuthReportsRoute,
@@ -625,7 +604,9 @@ const AuthRouteChildren: AuthRouteChildren = {
AuthSyncJournalRoute: AuthSyncJournalRoute, AuthSyncJournalRoute: AuthSyncJournalRoute,
AuthTariffsRoute: AuthTariffsRoute, AuthTariffsRoute: AuthTariffsRoute,
AuthTopologyRoute: AuthTopologyRoute, AuthTopologyRoute: AuthTopologyRoute,
AuthVpsRoute: AuthVpsRouteWithChildren, AuthVpsRoute: AuthVpsRoute,
AuthProjectsProjectIdRoute: AuthProjectsProjectIdRoute,
AuthVpsVpsIdRoute: AuthVpsVpsIdRoute,
} }
const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren) const AuthRouteWithChildren = AuthRoute._addFileChildren(AuthRouteChildren)
+10 -17
View File
@@ -24,7 +24,7 @@ import {
} from '@/components/domain/audit-labels' } from '@/components/domain/audit-labels'
import { Button } from '@cfdm/ui/components/button' import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { Tabs, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs' import { CountedLineTabs } from '@/components/counted-line-tabs'
import { TableSkeleton } from '@/components/skeletons' import { TableSkeleton } from '@/components/skeletons'
const auditSearchSchema = z.object({ const auditSearchSchema = z.object({
@@ -197,22 +197,15 @@ function AuditPage() {
actions={<AuditViewToggle view={view} onViewChange={setView} />} actions={<AuditViewToggle view={view} onViewChange={setView} />}
/> />
<Tabs value={actionFilter} onValueChange={setActionFilter}> <CountedLineTabs
<TabsList variant="line" aria-label="Фильтр по действию" className="gap-5"> tabs={ACTION_FILTERS.map((option) => ({
{ACTION_FILTERS.map((option) => ( id: option.value,
<TabsTrigger label: option.label,
key={option.value} count: actionCounts[option.value],
value={option.value} }))}
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3" value={actionFilter}
> onValueChange={setActionFilter}
<span>{option.label}</span> />
<span className="bg-muted text-muted-foreground rounded-md px-1.5 py-0.5 text-[10px] tabular-nums">
{actionCounts[option.value]}
</span>
</TabsTrigger>
))}
</TabsList>
</Tabs>
<QueryState <QueryState
data={data} data={data}
+80 -102
View File
@@ -29,9 +29,9 @@ import { dataGridCellStack } from '@/components/data-grid-cells'
import { KpiStatGridSkeleton, TableSkeleton } from '@/components/skeletons' import { KpiStatGridSkeleton, TableSkeleton } from '@/components/skeletons'
import { Button } from '@cfdm/ui/components/button' import { Button } from '@cfdm/ui/components/button'
import { Badge } from '@/components/reui/badge' import { Badge } from '@/components/reui/badge'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@cfdm/ui/components/tabs' import { TabsContent } from '@cfdm/ui/components/tabs'
import { CountedLineTabs } from '@/components/counted-line-tabs'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { cn } from '@cfdm/ui/lib/utils'
import { computeInventoryHealth } from '@/lib/inventory-health' import { computeInventoryHealth } from '@/lib/inventory-health'
import { buildAtRiskAccounts, type AtRiskAccount } from '@/lib/account-health' import { buildAtRiskAccounts, type AtRiskAccount } from '@/lib/account-health'
@@ -47,9 +47,6 @@ import { DashboardInventoryAlert } from '@/components/domain/dashboard-inventory
import type { Vps } from '@/types/entities' import type { Vps } from '@/types/entities'
const DASHBOARD_TAB_TRIGGER_CLASS =
'flex-none rounded-none border-0 border-b-2 border-transparent px-3 pb-2.5 pt-2 shadow-none after:hidden data-active:border-foreground data-active:bg-transparent data-active:shadow-none dark:data-active:border-foreground dark:data-active:bg-transparent'
const DASHBOARD_QUICK_ACTIONS: QuickActionItem[] = [ const DASHBOARD_QUICK_ACTIONS: QuickActionItem[] = [
{ {
id: 'vps', id: 'vps',
@@ -371,111 +368,92 @@ function DashboardPage() {
return ( return (
<div className="flex flex-col gap-4 md:gap-6"> <div className="flex flex-col gap-4 md:gap-6">
<DashboardInventoryAlert issuesCount={issuesCount} onGoToIssues={handleGoToIssues} />
<OpsDashboard <OpsDashboard
banner={
<DashboardInventoryAlert issuesCount={issuesCount} onGoToIssues={handleGoToIssues} />
}
title="Дашборд" title="Дашборд"
description="Сводка по VPS, балансам и здоровью инвентаря" description="Сводка по VPS, балансам и здоровью инвентаря"
kpiCards={statsLoading ? [] : kpiCards} kpiCards={statsLoading ? [] : kpiCards}
isLoading={statsLoading} isLoading={statsLoading}
charts={ >
<ChartsGrid> <div ref={tabsRef} className="scroll-mt-16">
<DashboardPaymentsChart <CountedLineTabs
payments={snap.payments} tabs={[
settings={snap.settings} { id: 'issues', label: 'Проблемы', count: issues.length || undefined },
ratesData={ratesData} { id: 'recent', label: 'Последние VPS' },
className="h-full" { id: 'risk', label: 'Аккаунты', count: atRisk.length || undefined },
/> ]}
<DashboardExpensesChart value={activeTab}
payments={snap.payments} onValueChange={setActiveTab}
balanceLedger={snap.balanceLedger ?? []} className="flex w-full flex-col gap-4 md:gap-6"
vps={snap.vps} >
providers={snap.providers} <TabsContent value="issues" className="mt-0 flex flex-col gap-4 md:gap-6">
settings={snap.settings} {snap.settings?.[0]?.showQuickActions !== false ? (
ratesData={ratesData} <QuickActionGrid
className="h-full" actions={DASHBOARD_QUICK_ACTIONS}
/> description="Частые разделы учёта и аналитики"
</ChartsGrid>
}
queue={
<div ref={tabsRef}>
<Tabs
value={activeTab}
onValueChange={setActiveTab}
className="flex w-full flex-col gap-4"
>
<TabsList variant="line" className="mb-0 h-auto w-fit gap-1 border-b border-border p-0">
<TabsTrigger value="issues" className={cn(DASHBOARD_TAB_TRIGGER_CLASS, 'gap-2')}>
Проблемы
{issues.length > 0 ? (
<Badge variant="secondary" size="sm" radius="full">
{issues.length}
</Badge>
) : null}
</TabsTrigger>
<TabsTrigger value="recent" className={DASHBOARD_TAB_TRIGGER_CLASS}>
Последние VPS
</TabsTrigger>
<TabsTrigger value="risk" className={cn(DASHBOARD_TAB_TRIGGER_CLASS, 'gap-2')}>
Аккаунты
{atRisk.length > 0 ? (
<Badge variant="outline" size="sm" radius="full">
{atRisk.length}
</Badge>
) : null}
</TabsTrigger>
</TabsList>
<TabsContent value="issues" className="mt-0">
<ResourcePage
title="Здоровье инвентаря"
description="Ставка, оплата, синк, баланс аккаунтов"
columns={columnDefFromDataGrid(issueColumns)}
data={issues}
getRowId={(i) => i.key}
emptyTitle="Проблем не найдено"
emptyDescription="Критичных проблем в инвентаре не обнаружено"
pagination={false}
/> />
</TabsContent> ) : null}
<TabsContent value="recent" className="mt-0"> <ChartsGrid>
<ResourcePage <DashboardPaymentsChart
title="Последние VPS" payments={snap.payments}
description="Активные серверы" settings={snap.settings}
actions={ ratesData={ratesData}
<Button variant="outline" size="sm" render={<Link to="/vps" />}> className="h-full"
Все VPS
</Button>
}
columns={columnDefFromDataGrid(vpsColumns)}
data={activeVps.slice(0, 8)}
getRowId={(v) => v.id}
pagination={false}
onRowClick={(v) => navigate({ to: '/vps', search: { edit: v.id } })}
/> />
</TabsContent> <DashboardExpensesChart
<TabsContent value="risk" className="mt-0"> payments={snap.payments}
<ResourcePage balanceLedger={snap.balanceLedger ?? []}
title="Аккаунты под риском" vps={snap.vps}
description="Низкий баланс или устаревший синк BILLmanager" providers={snap.providers}
columns={columnDefFromDataGrid(riskColumns)} settings={snap.settings}
data={atRisk} ratesData={ratesData}
getRowId={(r) => r.id} className="h-full"
emptyTitle="Рисков нет"
emptyDescription="Балансы и синхронизация в норме"
pagination={false}
/> />
</TabsContent> </ChartsGrid>
</Tabs> <ResourcePage
</div> title="Здоровье инвентаря"
} description="Ставка, оплата, синк, баланс аккаунтов"
/> columns={columnDefFromDataGrid(issueColumns)}
data={issues}
{snap.settings?.[0]?.showQuickActions !== false ? ( getRowId={(i) => i.key}
<QuickActionGrid emptyTitle="Проблем не найдено"
actions={DASHBOARD_QUICK_ACTIONS} emptyDescription="Критичных проблем в инвентаре не обнаружено"
description="Частые разделы учёта и аналитики" pagination={false}
/> />
) : null} </TabsContent>
<TabsContent value="recent" className="mt-0">
<ResourcePage
title="Последние VPS"
description="Активные серверы"
actions={
<Button variant="outline" size="sm" render={<Link to="/vps" />}>
Все VPS
</Button>
}
columns={columnDefFromDataGrid(vpsColumns)}
data={activeVps.slice(0, 8)}
getRowId={(v) => v.id}
pagination={false}
onRowClick={(v) => navigate({ to: '/vps', search: { edit: v.id } })}
/>
</TabsContent>
<TabsContent value="risk" className="mt-0">
<ResourcePage
title="Аккаунты под риском"
description="Низкий баланс или устаревший синк BILLmanager"
columns={columnDefFromDataGrid(riskColumns)}
data={atRisk}
getRowId={(r) => r.id}
emptyTitle="Рисков нет"
emptyDescription="Балансы и синхронизация в норме"
pagination={false}
/>
</TabsContent>
</CountedLineTabs>
</div>
</OpsDashboard>
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button <Button
@@ -1,4 +1,5 @@
import { createFileRoute, Link, useNavigate } from '@tanstack/react-router' import { createFileRoute, Link, useNavigate } from '@tanstack/react-router'
import { usePageCrumb } from '@/lib/page-crumb'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
import { import {
@@ -51,7 +52,7 @@ import {
} from '@/lib/project-analytics' } from '@/lib/project-analytics'
import type { Vps } from '@/types/entities' import type { Vps } from '@/types/entities'
export const Route = createFileRoute('/_auth/projects/$projectId')({ export const Route = createFileRoute('/_auth/projects_/$projectId')({
loader: ({ context: { queryClient } }) => loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(snapshotQueryOptions()), queryClient.ensureQueryData(snapshotQueryOptions()),
component: ProjectDetailPage, component: ProjectDetailPage,
@@ -73,6 +74,7 @@ function ProjectDetailPage() {
const [editOpen, setEditOpen] = useState(false) const [editOpen, setEditOpen] = useState(false)
const project = snapshot ? findProject(snapshot, projectId) : undefined const project = snapshot ? findProject(snapshot, projectId) : undefined
usePageCrumb(project?.name)
const projectVps = useMemo( const projectVps = useMemo(
() => (snapshot ? projectVpsList(snapshot, projectId) : []), () => (snapshot ? projectVpsList(snapshot, projectId) : []),
[snapshot, projectId], [snapshot, projectId],
+4 -1
View File
@@ -683,7 +683,10 @@ function VpsPage() {
{tableSections.map((section) => ( {tableSections.map((section) => (
<ResourcePage <ResourcePage
key={section.key} key={section.key}
title={section.label ?? undefined} title={section.label ?? 'Инвентарь VPS'}
description={
section.label ? undefined : 'Поиск по IP, DNS, проекту, назначению, ОС'
}
columns={columnDefFromDataGrid(columns)} columns={columnDefFromDataGrid(columns)}
data={section.items} data={section.items}
getRowId={(v) => v.id} getRowId={(v) => v.id}
@@ -1,4 +1,5 @@
import { createFileRoute, Link } from '@tanstack/react-router' import { createFileRoute, Link } from '@tanstack/react-router'
import { usePageCrumb } from '@/lib/page-crumb'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import { useMemo } from 'react' import { useMemo } from 'react'
import { import {
@@ -48,7 +49,7 @@ import {
import type { Payment, Vps } from '@/types/entities' import type { Payment, Vps } from '@/types/entities'
import { VpsDomainsCell } from '@/components/integrations/vps-domains-cell' import { VpsDomainsCell } from '@/components/integrations/vps-domains-cell'
export const Route = createFileRoute('/_auth/vps/$vpsId')({ export const Route = createFileRoute('/_auth/vps_/$vpsId')({
loader: ({ context: { queryClient } }) => loader: ({ context: { queryClient } }) =>
queryClient.ensureQueryData(snapshotQueryOptions()), queryClient.ensureQueryData(snapshotQueryOptions()),
component: VpsDetailPage, component: VpsDetailPage,
@@ -95,6 +96,7 @@ function VpsDetailPage() {
const ratesData = normalizeRatesPayload(rawRates) ?? rawRates ?? null const ratesData = normalizeRatesPayload(rawRates) ?? rawRates ?? null
const vps = snapshot?.vps.find((v) => v.id === vpsId) const vps = snapshot?.vps.find((v) => v.id === vpsId)
usePageCrumb(vps ? vps.ip || vps.dns || 'VPS' : null)
const providerById = snapshot ? providerByIdMap(snapshot.providers) : new Map() const providerById = snapshot ? providerByIdMap(snapshot.providers) : new Map()
const account = snapshot?.providerAccounts.find((a) => a.id === vps?.providerAccountId) const account = snapshot?.providerAccounts.find((a) => a.id === vps?.providerAccountId)
const provider = vps ? providerById.get(vps.providerId) : undefined const provider = vps ? providerById.get(vps.providerId) : undefined
@@ -153,19 +155,18 @@ function VpsDetailPage() {
title={vps ? (vps.ip || vps.dns || 'VPS') : 'VPS'} title={vps ? (vps.ip || vps.dns || 'VPS') : 'VPS'}
description={account ? accountSelectLabel(account, providerById) : undefined} description={account ? accountSelectLabel(account, providerById) : undefined}
actions={ actions={
vps ? ( <>
<Button variant="outline" render={<Link to="/vps" search={{ edit: vpsId }} />}> <Button variant="outline" render={<Link to="/vps" />}>
Редактировать <ArrowLeftIcon data-icon="inline-start" />
К списку VPS
</Button> </Button>
) : undefined {vps ? (
<Button render={<Link to="/vps" search={{ edit: vpsId }} />}>Редактировать</Button>
) : null}
</>
} }
/> />
<Button variant="ghost" size="sm" className="w-fit" render={<Link to="/vps" />}>
<ArrowLeftIcon data-icon="inline-start" />
К списку VPS
</Button>
<QueryState <QueryState
data={vps} data={vps}
isLoading={isLoading} isLoading={isLoading}
@@ -189,12 +190,22 @@ function VpsDetailPage() {
} }
> >
{(row) => ( {(row) => (
<Tabs defaultValue="overview"> <Tabs defaultValue="overview" className="flex flex-col gap-4 md:gap-6">
<TabsList> <TabsList variant="line" className="gap-5">
<TabsTrigger value="overview">Обзор</TabsTrigger> {[
<TabsTrigger value="finance">Финансы</TabsTrigger> ['overview', 'Обзор'],
<TabsTrigger value="notes">Заметки</TabsTrigger> ['finance', 'Финансы'],
<TabsTrigger value="sync">Синк</TabsTrigger> ['notes', 'Заметки'],
['sync', 'Синк'],
].map(([value, label]) => (
<TabsTrigger
key={value}
value={value}
className="text-muted-foreground hover:text-foreground h-auto px-0 pb-3 after:bottom-0"
>
{label}
</TabsTrigger>
))}
</TabsList> </TabsList>
<TabsContent value="overview" className="flex flex-col gap-4"> <TabsContent value="overview" className="flex flex-col gap-4">
+112
View File
@@ -0,0 +1,112 @@
/*
* VPS-Tracker theme layer (design canvas «VPS-Tracker», see docs/ui-migration-plan.md).
* Overrides tokens only — @cfdm/ui/globals.css stays shadcn CLI output, ReUI components are not restyled.
*/
@import '@fontsource-variable/geist';
@import '@fontsource-variable/geist-mono';
:root {
--font-sans: 'Geist Variable', ui-sans-serif, system-ui, sans-serif;
--font-mono: 'Geist Mono Variable', ui-monospace, SFMono-Regular, monospace;
--background: oklch(0.973 0.004 247.858);
--foreground: oklch(0.141 0.006 264);
--card: oklch(1 0 0);
--card-foreground: oklch(0.141 0.006 264);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.141 0.006 264);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.958 0.005 247.858);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.958 0.005 247.858);
--muted-foreground: oklch(0.52 0.01 264);
--accent: oklch(0.942 0.016 264.376);
--accent-foreground: oklch(0.205 0 0);
--border: oklch(0.904 0.006 264);
--input: oklch(0.904 0.006 264);
--ring: oklch(0.488 0.243 264.376);
--destructive: oklch(0.577 0.245 27.325);
--destructive-foreground: oklch(0.444 0.177 26.899);
--success: oklch(0.696 0.17 162.48);
--success-foreground: oklch(0.378 0.077 168.94);
--warning: oklch(0.795 0.184 86.047);
--warning-foreground: oklch(0.421 0.095 57.708);
--info: oklch(0.606 0.25 292.717);
--info-foreground: oklch(0.283 0.141 291.089);
--invert: oklch(0.21 0.006 285.885);
--invert-foreground: oklch(0.985 0 0);
--chart-1: oklch(0.546 0.215 264.376);
--chart-2: oklch(0.527 0.154 150.069);
--chart-3: oklch(0.625 0.162 41.5);
--chart-4: oklch(0.591 0.224 296.4);
/* Sidebar stays dark in the light theme. */
--sidebar: oklch(0.152 0.01 264.376);
--sidebar-foreground: oklch(0.92 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.24 0.012 264.376);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 8%);
--sidebar-ring: oklch(0.488 0.243 264.376);
}
/* Dark-sidebar scope in light theme: inner text/border/muted tokens follow the sidebar. */
:root:not(.dark) :is([data-slot='sidebar-inner'], [data-slot='sidebar'][data-mobile='true']) {
--foreground: oklch(0.92 0 0);
--muted-foreground: oklch(0.648 0.008 264.376);
--border: oklch(1 0 0 / 8%);
--muted: oklch(0.24 0.012 264.376);
--background: oklch(0.133 0.011 264.376);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--card: oklch(0.191 0.011 264.376);
--input: oklch(1 0 0 / 15%);
--accent: oklch(0.24 0.012 264.376);
--accent-foreground: oklch(0.985 0 0);
color-scheme: dark;
}
.dark {
--background: oklch(0.133 0.011 264.376);
--foreground: oklch(0.985 0 0);
--card: oklch(0.191 0.011 264.376);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.191 0.011 264.376);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.252 0.01 264.376);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.252 0.01 264.376);
--muted-foreground: oklch(0.648 0.008 264.376);
--accent: oklch(0.285 0.022 264.376);
--accent-foreground: oklch(0.985 0 0);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.488 0.243 264.376);
--destructive: oklch(0.704 0.191 22.216);
--destructive-foreground: oklch(0.577 0.245 27.325);
--success: oklch(0.696 0.17 162.48);
--success-foreground: oklch(0.596 0.145 163.225);
--warning: oklch(0.795 0.184 86.047);
--warning-foreground: oklch(0.681 0.162 75.834);
--info: oklch(0.606 0.25 292.717);
--info-foreground: oklch(0.541 0.281 293.009);
--invert: oklch(0.37 0.013 285.805);
--invert-foreground: oklch(0.985 0 0);
--chart-1: oklch(0.637 0.222 264.376);
--chart-2: oklch(0.696 0.17 151.328);
--chart-3: oklch(0.713 0.176 47.6);
--chart-4: oklch(0.714 0.173 303.9);
--sidebar: oklch(0.191 0.011 264.376);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.262 0.014 264.376);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.488 0.243 264.376);
}
+9 -4
View File
@@ -15,7 +15,7 @@ Project lock: **`surface: frame`**. Ops / list / dashboard / detail / settings
export const UI_SURFACE = 'frame' as const export const UI_SURFACE = 'frame' as const
``` ```
Settings: секции через Frame + `gap` (без hairline `Separator` под PageHeader); `SettingRow` без `FieldSeparator` по умолчанию (`separated` opt-in). Preview: [settings-3](https://reui.io/preview/base/settings-3) · [settings-16](https://reui.io/preview/base/settings-16). Settings: **line-tabs сверху** (Общие / Синхронизация / Уведомления / Интеграции) вместо боковой навигации; секции через Frame + `gap` (без hairline `Separator` под PageHeader); `SettingRow` без `FieldSeparator` по умолчанию (`separated` opt-in). Preview: [settings-3](https://reui.io/preview/base/settings-3) · [settings-16](https://reui.io/preview/base/settings-16).
## Canonical PRO references ## Canonical PRO references
@@ -31,6 +31,10 @@ Settings: секции через Frame + `gap` (без hairline `Separator` п
| Empty | `empty-state-14` (prefer) / `empty-state-12` | https://reui.io/preview/base/empty-state-14 · https://reui.io/preview/base/empty-state-12 | | Empty | `empty-state-14` (prefer) / `empty-state-12` | https://reui.io/preview/base/empty-state-14 · https://reui.io/preview/base/empty-state-12 |
| Forms | `form-7` → Sheet/Drawer | https://reui.io/preview/base/form-7 | | Forms | `form-7` → Sheet/Drawer | https://reui.io/preview/base/form-7 |
## Theme
Палитра — нейтрали hue 264 (макет «VPS-Tracker»), шрифты **Geist / Geist Mono**. Оверрайд токенов — `apps/web/src/styles/theme.css` поверх `@cfdm/ui/globals.css` (globals — только вывод CLI). В светлой теме сайдбар остаётся тёмным. План миграции: [`ui-migration-plan.md`](ui-migration-plan.md).
## Kit API (`reui-kit/`) ## Kit API (`reui-kit/`)
| Component | Role | | Component | Role |
@@ -51,7 +55,7 @@ Settings: секции через Frame + `gap` (без hairline `Separator` п
| App | Section order | | App | Section order |
|-----|---------------| |-----|---------------|
| EvoBGP / CFDM / EvoFirewall | KPI → **QuickActionGrid** → charts / rest | | EvoBGP / CFDM / EvoFirewall | KPI → **QuickActionGrid** → charts / rest |
| vps-tracker | banner → KPI → charts → attention → **QuickActionGrid** → CSV | | vps-tracker | banner → KPI → line-tabs «Проблемы» (QuickActionGrid → charts → attention) · «Последние VPS» · «Аккаунты» → CSV |
| auth-portal | portal-specific; Quick Actions при наличии dashboard | | auth-portal | portal-specific; Quick Actions при наличии dashboard |
Gating: DB `show_quick_actions` / `showQuickActions` / `ui_show_quick_actions` (default `true`). Gating: DB `show_quick_actions` / `showQuickActions` / `ui_show_quick_actions` (default `true`).
@@ -67,8 +71,9 @@ Gating: DB `show_quick_actions` / `showQuickActions` / `ui_show_quick_actions` (
| Sidebar / hover colors | theme `--sidebar` / `--sidebar-accent` — **без** AppShell `color-mix` override | | Sidebar / hover colors | theme `--sidebar` / `--sidebar-accent` — **без** AppShell `color-mix` override |
| Header | `h-12`, `sticky`, `border-b`, `px-4 md:px-6` | | Header | `h-12`, `sticky`, `border-b`, `px-4 md:px-6` |
| Header left | `SidebarTrigger` + `Separator` + Breadcrumb | | Header left | `SidebarTrigger` + `Separator` + Breadcrumb |
| Header right | **AppsMenu** → **SystemMonitorPopover** (тема — в NavUser) | | Header right | **SystemMonitorPopover** (пилюля «Внимание»/«Норма»; приложения — в AppSwitcher; тема — в NavUser) |
| Sidebar | AppSwitcher → groups → icons `size-4` → **NavUser** в `SidebarFooter` | | Sidebar | AppSwitcher (лого + подзаголовок + список приложений) → SpaceSwitcher → **сворачиваемые** группы (UPPERCASE label + шеврон, `collapsible`) → icons `size-4` → **NavUser** (muted-карточка) в `SidebarFooter` |
| Проблемы в меню | точка `destructive`/`warning` у пункта (не числовой Badge) |
| `main` | `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5` | | `main` | `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5` |
| Search | hotkey ⌘K / Ctrl+K only (не кнопка в header) | | Search | hotkey ⌘K / Ctrl+K only (не кнопка в header) |
+93
View File
@@ -0,0 +1,93 @@
# План миграции UI vps-tracker на дизайн «VPS-Tracker»
Источник: артефакт-канвас [VPS-Tracker](https://claude.ai/artifact/7kPVbQNXa4AWgFkSekZJiN#page-dcb81aee8d83), доски `Vps*.dc.html` + шаблон `TplVps.dc.html` + токены `reui-auth.css`.
Канвас — макет, имитирующий ReUI; его CSS **не переносим**. Используем как спецификацию регионов и токенов, а реализуем на ReUI PRO + shadcn primitives (контракт: [`ui-design-contract.md`](ui-design-contract.md)).
Лицензия ReUI PRO: `REUI_LICENSE_KEY` есть, премиум-реестр отвечает 200 (проверено на `app-shell-12`).
## Что не является страницами
- Блоки `states` («Справочник состояний · не часть страницы») — только спецификация empty/loading/error, на страницах не рендерим.
- Доски `VpsCollapsed`, `VpsLight`, `VpsMobile`, `VpsPalette` — состояния одного экрана; используем для проверки (свёрнутый сайдбар, светлая тема, ширина телефона, ⌘K).
- Инструменты канваса: полосы `wedit`, «звёздочки»/закрепы в меню, атрибуты density/contrast — пропускаем.
## Сохраняем то, чего нет в макете
`SpaceSwitcher` (пространства — реальная функция), `GlobalSearch` (⌘K), фильтры ReUI `Filters` в списках, CSV-экспорт, row actions. Макет их не отменяет.
## Сопоставление блоков макета → ReUI
| Блок макета | Реализация | ReUI / shadcn |
|---|---|---|
| sidebar `asbtn` (лого + «Учёт серверов и платежей») + поповер приложений | `AppSwitcher` (+ пункты бывшего `AppsMenu`) | shadcn `sidebar`, `dropdown-menu`; эталон [app-shell-12](https://reui.io/preview/base/app-shell-12) `brand.tsx` |
| `grph` — сворачиваемые группы (UPPERCASE + шеврон) | `NavGroup` в `app-shell.tsx` | shadcn `sidebar` + `collapsible` (паттерн Collapsible SidebarGroup) |
| `pdot` — точка проблем у пункта меню | `SidebarMenuBadge` / точка `bg-destructive` | shadcn `sidebar` |
| `user` — NavUser карточкой | `NavUser` | [app-shell-1](https://reui.io/preview/base/app-shell-1) user menu |
| header: trigger + crumbs + `statb` | `AppShell` header, без `AppsMenu` | shadcn `breadcrumb`, `popover` |
| `statb` «Внимание · ●1 ●2» → монитор | триггер `SystemMonitorPopover` | `@reui/badge` (`warning-light`/`destructive-light`) |
| h1 + sub + actions | `PageHeader` | — |
| `banner` / `note` | `Alert` | `@reui/alert` |
| `kpi` (иконка слева, label, value, chip) | `KpiStatGrid` | `@reui/icon-tile`, `@reui/badge`, Frame ([stats-12](https://reui.io/preview/base/stats-12)) |
| `ptabs` — вкладки страницы со счётчиками | `CountedLineTabs` | shadcn `tabs` variant `line` (c-tabs-2) |
| `seg` — сегменты вида/снимка | `ToggleGroup` | shadcn `toggle-group` |
| `grid` — Frame: заголовок, поиск, колонки, виды, bulk, футер | `ResourcePage` / `FrameDataGrid` | `@reui/data-grid`, `@reui/filters`, `@reui/frame` ([data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2)) |
| `cards` (быстрые действия) | `QuickActionGrid` | Frame tiles |
| `chart` | Recharts через shadcn `chart` | — |
| `kv` | `DetailPanel` | `@reui/frame` |
| `timeline` | `Timeline` | `@reui/timeline` |
| `form` (`frow`) | `SettingRow` | [settings-16](https://reui.io/preview/base/settings-16) |
## Тема и шрифты
- Палитра макета: нейтрали с оттенком **hue 264** (`--background: oklch(0.133 0.011 264.376)` и т.д.), `info` — фиолетовый, `chart-1..4` из макета; в светлой теме **сайдбар остаётся тёмным**.
- Где живёт: `packages/ui/src/styles/globals.css` — только вывод `shadcn` CLI, руками не трогаем. Оверрайд — слой приложения **`apps/web/src/styles/theme.css`** (импорт после `@cfdm/ui/globals.css` в `main.tsx`): переопределяет переменные `:root`/`.dark` и `--sidebar*` для светлой темы. Компоненты ReUI не рестайлим — меняются только токены.
- Шрифты: **Geist** / **Geist Mono** через `@fontsource-variable/geist` и `@fontsource-variable/geist-mono`; `--font-sans` / `--font-mono` в `theme.css`.
## Изменения по экранам (`apps/web/src/routes/_auth/*`)
| Маршрут | Доска | Изменения | Статус |
|---|---|---|---|
| `dashboard` | VpsDashboard | баннер → KPI (6) → **вкладки** «Проблемы» (быстрые действия, графики Платежи/Расходы, «Здоровье инвентаря») · «Последние VPS» · «Аккаунты» (под риском) | сделано |
| `vps` | VpsList | заголовок «VPS / Виртуальные серверы», Alert несвязанных доменов, Frame-грид «Инвентарь VPS» с подзаголовком | сделано (заголовок грида); Alert доменов и фильтры — уже были |
| `vps.$vpsId` | VpsDetail | крошки VPS / IP, действия «К списку VPS» + «Редактировать», **вкладки** Обзор / Финансы / Заметки / Синк | сделано (line-вкладки, действия, крошки «VPS / IP»); маршрут вынесен в `vps_.$vpsId` |
| `accounts` | VpsAccounts | KPI (4) + грид, действия «Синхронизировать все» + «Добавить» | уже соответствует («Синхронизировать все» — при наличии API-аккаунтов) |
| `providers` | VpsProviders | грид «Хостеры» | уже соответствует |
| `projects`, `projects.$projectId` | VpsProjects / VpsProjectDetail | KPI (4) + грид | уже соответствует; крошки «Проекты / имя»; маршрут вынесен в `projects_.$projectId` |
| `tariffs` | VpsTariffs | Alert расхождений + грид | уже соответствует («Загрузить тарифы» — при наличии API) |
| `payments` | VpsPayments | KPI (3) + грид | уже соответствует |
| `balance` | VpsBalance | KPI (3) + грид, футер с итогами | уже соответствует |
| `reports`, `resources` | VpsReports / VpsResources | сегмент периода, KPI, графики в Frame | уже соответствует |
| `renewals` | VpsRenewals | KPI (3) + грид | уже соответствует |
| `blocking`, `geo` | VpsBlocking / VpsGeo | KPI (4) + сегменты + вкладки Текущие / История | уже соответствует |
| `topology` | VpsTopology | без изменений структуры (холст) | без изменений |
| `spaces` | VpsSpaces | секции Frame | уже соответствует |
| `sync-journal` | VpsSync | грид «Запуски синхронизации» | уже соответствует |
| `audit` | VpsAudit | вкладки по действию + переключатель Лента / Таблица | сделано (общие CountedLineTabs) |
| `settings/*` | VpsSettings | **вкладки сверху** Общие / Синхронизация / Уведомления / Интеграции вместо боковой навигации `SettingsShell` | сделано |
## Общие компоненты
- `layout/app-shell.tsx` — сворачиваемые группы, точки проблем, header без `AppsMenu`.
- `app-switcher.tsx` — подзаголовок, приложения из `AppsMenu`; `layout/apps-menu.tsx` удаляется из header.
- `layout/nav-user.tsx` — карточка (muted фон), меню Настройки / Тема / Выйти (без изменений логики).
- `layout/system-monitor-popover.tsx` — триггер-пилюля «Внимание» со счётчиками ошибок/предупреждений, футер «Журнал синка».
- `reui-kit/settings-shell.tsx` — line-tabs сверху.
- `counted-line-tabs.tsx` — счётчик моноширинным текстом как в макете.
## Порядок
1. План + контракт (этот документ, [`ui-design-contract.md`](ui-design-contract.md)).
2. Тема и шрифты (`theme.css`, fontsource).
3. Shell: AppSwitcher, группы, NavUser, header, монитор.
4. Kit: `CountedLineTabs`, `SettingsShell`.
5. Экраны: dashboard → VPS detail → settings → audit → blocking/geo → прочие (тексты, колонки).
6. Проверка: dev-сервер, 1440×1000 + свёрнутый сайдбар + светлая тема + телефон; `pnpm --filter web build`, `pnpm --filter web test`; `validate_usage`, `get_audit_checklist`.
## Установка
Новые ReUI-элементы не требуются: всё нужное уже установлено (`frame`, `badge`, `alert`, `data-grid`, `filters`, `timeline`, `icon-tile`) и shadcn (`sidebar`, `collapsible`, `tabs`, `toggle-group`). Добавляются только пакеты шрифтов. Если при реализации понадобится новый элемент — `cd apps/web && pnpm dlx shadcn@latest add @reui/<name>`.
## Итог проверки
- Сверка: артефакт в браузере не рендерится без входа в claude.ai, поэтому соответствие проверено по структуре досок `.dc.html` (заголовки, подзаголовки, действия, KPI, вкладки, блоки) и скриншотам dev-сервера 1440×1000, свёрнутого меню, светлой темы и ширины телефона.
- «Уже соответствует» — экран сверен по тексту (`h1`, подзаголовок, действия) с доской; визуальные отличия закрыты общими изменениями темы, shell, KPI и шапки страницы.
+16
View File
@@ -114,6 +114,12 @@ importers:
'@dnd-kit/utilities': '@dnd-kit/utilities':
specifier: ^3.2.2 specifier: ^3.2.2
version: 3.2.2(react@19.2.7) version: 3.2.2(react@19.2.7)
'@fontsource-variable/geist':
specifier: ^5.3.0
version: 5.3.0
'@fontsource-variable/geist-mono':
specifier: ^5.3.0
version: 5.3.0
'@hookform/resolvers': '@hookform/resolvers':
specifier: ^3.10.0 specifier: ^3.10.0
version: 3.10.0(react-hook-form@7.80.0(react@19.2.7)) version: 3.10.0(react-hook-form@7.80.0(react@19.2.7))
@@ -978,6 +984,12 @@ packages:
'@floating-ui/utils@0.2.11': '@floating-ui/utils@0.2.11':
resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==}
'@fontsource-variable/geist-mono@5.3.0':
resolution: {integrity: sha512-vBbuwDEo9AkrqADMXOrlAR3DFcJi4/JxeuU43FoiQERnNwsfXNnvxvReZG02cQKmyk4DZkZdBZX3oTDvy2zBAw==}
'@fontsource-variable/geist@5.3.0':
resolution: {integrity: sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==}
'@hookform/resolvers@3.10.0': '@hookform/resolvers@3.10.0':
resolution: {integrity: sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==} resolution: {integrity: sha512-79Dv+3mDF7i+2ajj7SkypSKHhl1cbln1OGavqrsF7p6mbUv11xpqpacPsGDCTRvCSjEEIez2ef1NveSVL3b0Ag==}
peerDependencies: peerDependencies:
@@ -4145,6 +4157,10 @@ snapshots:
'@floating-ui/utils@0.2.11': {} '@floating-ui/utils@0.2.11': {}
'@fontsource-variable/geist-mono@5.3.0': {}
'@fontsource-variable/geist@5.3.0': {}
'@hookform/resolvers@3.10.0(react-hook-form@7.80.0(react@19.2.7))': '@hookform/resolvers@3.10.0(react-hook-form@7.80.0(react@19.2.7))':
dependencies: dependencies:
react-hook-form: 7.80.0(react@19.2.7) react-hook-form: 7.80.0(react@19.2.7)