feat(web): перевести shell и дашборд на дизайн VPS-Tracker
Сворачиваемые группы меню с точками проблем, приложения в AppSwitcher вместо AppsMenu в шапке, пилюля монитора со счётчиками, KPI со значением и бейджем в одной строке, вкладки дашборда на уровне страницы. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
10b20b9d55
commit
b5300b9082
10 files changed
+256
-330
No files matched your search
@@ -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}
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -62,12 +66,14 @@ import {
|
|||||||
} from '@/lib/auth'
|
} from '@/lib/auth'
|
||||||
import { SpaceProvider, useSpaceId } from '@/lib/space'
|
import { SpaceProvider, useSpaceId } from '@/lib/space'
|
||||||
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 {
|
||||||
@@ -162,9 +168,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 +208,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 +218,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 ? (
|
||||||
@@ -255,7 +238,6 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
</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>
|
||||||
@@ -270,6 +252,60 @@ 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 />}
|
||||||
|
className="group/label hover:text-sidebar-foreground cursor-pointer uppercase tracking-wider transition-colors"
|
||||||
|
>
|
||||||
|
{group.label}
|
||||||
|
<ChevronDownIcon className="ml-auto transition-transform group-not-data-panel-open/label:-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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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'
|
|
||||||
}
|
}
|
||||||
@@ -63,11 +63,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 +87,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 +98,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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in new issue
Block a user