fix(ui): выровнять chrome Frame и убрать Card-оболочки
Docker images / prepare-release (push) Successful in 4s
Docker images / backend-image (push) Failing after 2m36s
Docker images / frontend-image (push) Successful in 2m22s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 55s
Docker images / publish-release (push) Skipped
Docker images / prepare-release (push) Successful in 4s
Docker images / backend-image (push) Failing after 2m36s
Docker images / frontend-image (push) Successful in 2m22s
Docker images / notify-webhook (push) Skipped
Docker images / updater-image (push) Successful in 55s
Docker images / publish-release (push) Skipped
Подключить App Switcher, NavUser, OpsPanel и AlertDialog вместо Card-shell. Co-authored-by: Cursor <[email protected]>
This commit is contained in:
@@ -1,18 +1,16 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useTheme } from "@/components/theme-provider"
|
||||
import { NavMain, type NavGroup } from "@/components/nav-main"
|
||||
import { SearchIcon } from "lucide-react"
|
||||
import { AppSwitcher } from "@/components/app-switcher"
|
||||
import { NavUser } from "@/components/nav-user"
|
||||
import {
|
||||
Sidebar,
|
||||
SidebarContent,
|
||||
SidebarFooter,
|
||||
SidebarHeader,
|
||||
SidebarRail,
|
||||
} from "@/components/ui/sidebar"
|
||||
import {
|
||||
RouterIcon,
|
||||
LayoutDashboardIcon,
|
||||
ActivityIcon,
|
||||
MapIcon,
|
||||
@@ -34,8 +32,6 @@ import {
|
||||
CableIcon,
|
||||
ScanLineIcon,
|
||||
BellIcon,
|
||||
SunIcon,
|
||||
MoonIcon,
|
||||
ShieldCheckIcon,
|
||||
BoxIcon,
|
||||
BadgeCheckIcon,
|
||||
@@ -47,25 +43,6 @@ import {
|
||||
mockSidebarBadgesByUrl,
|
||||
type SidebarCountsDto,
|
||||
} from "@/lib/sidebar-badges"
|
||||
import { formatAppVersionLabel, getAppVersion } from "@/lib/app-version"
|
||||
|
||||
// ─── Command palette trigger button ──────────────────────────────────────────
|
||||
|
||||
function CommandPaletteButton() {
|
||||
function trigger() {
|
||||
window.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true, bubbles: true }))
|
||||
}
|
||||
return (
|
||||
<button
|
||||
onClick={trigger}
|
||||
className="group-data-[collapsible=icon]:hidden mx-2 mb-1 flex items-center gap-2 px-2.5 py-1.5 rounded-md border border-sidebar-border/60 bg-sidebar-accent/30 hover:bg-sidebar-accent text-sidebar-foreground/50 hover:text-sidebar-foreground text-xs transition-colors w-[calc(100%-1rem)]"
|
||||
>
|
||||
<SearchIcon className="size-3 shrink-0" />
|
||||
<span className="flex-1 text-left">Поиск…</span>
|
||||
<span className="font-mono text-[10px] border border-sidebar-border/60 rounded px-1 py-0.5">⌘K</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
type NavItemBase = { title: string; url: string; icon: React.ReactNode }
|
||||
|
||||
@@ -127,7 +104,6 @@ const navStructure: { label: string; items: NavItemBase[] }[] = [
|
||||
type LiveSidebarCounts = SidebarCountsDto & { greTunnels?: number; certificates?: number }
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
const { resolvedTheme, setTheme } = useTheme()
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const evo = useEvoBGP()
|
||||
const [mounted, setMounted] = React.useState(false)
|
||||
@@ -216,55 +192,17 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
}))
|
||||
}, [mounted, mode, liveCounts, mockBadges, evo.enabled, evo.snapshot])
|
||||
|
||||
const isDark = (resolvedTheme ?? "dark") === "dark"
|
||||
const appVersionLabel = formatAppVersionLabel(getAppVersion())
|
||||
|
||||
return (
|
||||
<Sidebar collapsible="icon" {...props}>
|
||||
<SidebarHeader>
|
||||
<div className="flex items-center gap-2.5 px-2 py-1.5 overflow-hidden">
|
||||
<div className="flex aspect-square size-8 shrink-0 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
||||
<RouterIcon className="size-4" />
|
||||
</div>
|
||||
<div className="flex flex-col leading-tight group-data-[collapsible=icon]:hidden">
|
||||
<span className="font-semibold text-sidebar-foreground text-sm tracking-tight">MikrotikManager</span>
|
||||
<span className="text-[10px] font-mono text-sidebar-foreground/40">{appVersionLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<CommandPaletteButton />
|
||||
<AppSwitcher />
|
||||
</SidebarHeader>
|
||||
<SidebarContent>
|
||||
<NavMain groups={navGroups} />
|
||||
</SidebarContent>
|
||||
<SidebarFooter>
|
||||
<div className="flex items-center gap-2 px-2 py-2 group-data-[collapsible=icon]:justify-center">
|
||||
<div className="size-7 rounded-full bg-gradient-to-br from-blue-500 to-violet-600 flex items-center justify-center text-white text-[10px] font-semibold shrink-0">
|
||||
АК
|
||||
</div>
|
||||
<div className="flex flex-col leading-tight min-w-0 flex-1 group-data-[collapsible=icon]:hidden">
|
||||
<span className="text-[13px] font-medium text-sidebar-foreground truncate">а.коротаев</span>
|
||||
<span className="text-[11px] text-sidebar-foreground/50 truncate">admin@routerlists.io</span>
|
||||
</div>
|
||||
{mounted ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setTheme(isDark ? "light" : "dark")}
|
||||
title={isDark ? "Светлая тема" : "Тёмная тема"}
|
||||
className="shrink-0 size-6 flex items-center justify-center rounded-md text-sidebar-foreground/50 hover:text-sidebar-foreground hover:bg-sidebar-accent transition-colors group-data-[collapsible=icon]:hidden"
|
||||
>
|
||||
{isDark
|
||||
? <SunIcon className="size-3.5" />
|
||||
: <MoonIcon className="size-3.5" />}
|
||||
</button>
|
||||
) : (
|
||||
<span
|
||||
className="shrink-0 size-6 group-data-[collapsible=icon]:hidden"
|
||||
aria-hidden
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<NavUser />
|
||||
</SidebarFooter>
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"use client"
|
||||
|
||||
import { CheckIcon, ChevronsUpDownIcon } from "lucide-react"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuShortcut,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
SidebarMenu,
|
||||
SidebarMenuButton,
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
import {
|
||||
APP_SWITCHER_ICONS,
|
||||
CURRENT_APP_ID,
|
||||
getCurrentApp,
|
||||
} from "@/lib/app-switcher-config"
|
||||
import { useAppSwitcherConfig } from "@/hooks/use-app-switcher"
|
||||
|
||||
/** Sidebar brand switcher — app-shell-12. @see https://reui.io/preview/base/app-shell-12 */
|
||||
export function AppSwitcher() {
|
||||
const { isMobile } = useSidebar()
|
||||
const { config, isLoading } = useAppSwitcherConfig()
|
||||
const current = getCurrentApp(config)
|
||||
const CurrentIcon = APP_SWITCHER_ICONS[current.icon] ?? APP_SWITCHER_ICONS.server
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />
|
||||
}
|
||||
>
|
||||
<div
|
||||
className="flex aspect-square size-8 items-center justify-center rounded-md bg-primary text-primary-foreground"
|
||||
aria-hidden
|
||||
>
|
||||
<CurrentIcon className="size-4" />
|
||||
</div>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{current.name}</span>
|
||||
{current.subtitle ? (
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{current.subtitle}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<ChevronsUpDownIcon className="ml-auto size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="min-w-56 rounded-lg"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
>
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{isLoading ? "Загрузка…" : config.menuLabel}
|
||||
</div>
|
||||
{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>
|
||||
<Icon />
|
||||
{app.name}
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={app.id}
|
||||
render={<a href={app.url} />}
|
||||
>
|
||||
<Icon />
|
||||
{app.name}
|
||||
{app.shortcut ? (
|
||||
<DropdownMenuShortcut>{app.shortcut}</DropdownMenuShortcut>
|
||||
) : null}
|
||||
</DropdownMenuItem>
|
||||
)
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
"use client"
|
||||
|
||||
import { LayoutGridIcon } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuGroup,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
APP_SWITCHER_ICONS,
|
||||
CURRENT_APP_ID,
|
||||
authPortalBaseUrl,
|
||||
} from "@/lib/app-switcher-config"
|
||||
import { useAppSwitcherConfig } from "@/hooks/use-app-switcher"
|
||||
|
||||
/** Header apps grid — app-shell-12. @see https://reui.io/preview/base/app-shell-12 */
|
||||
export function AppsMenu() {
|
||||
const { config, isLoading } = useAppSwitcherConfig()
|
||||
const portalBase = authPortalBaseUrl()
|
||||
const portalAppsUrl = portalBase ? `${portalBase}/admin/apps` : null
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<Button variant="ghost" size="icon" aria-label="Приложения" />
|
||||
}
|
||||
>
|
||||
<LayoutGridIcon className="size-4.5" 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}
|
||||
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>
|
||||
{portalAppsUrl ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
render={<a href={portalAppsUrl} />}
|
||||
className="justify-center text-sm font-medium"
|
||||
>
|
||||
Настроить на портале
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useMemo, useState } from "react"
|
||||
import { Flag } from "@/components/flag"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { OpsPanel } from "@/components/ops-panel"
|
||||
import { StatusBadge } from "@/components/status-badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { InternetPathViewModel } from "@/lib/dashboard-internet-path"
|
||||
@@ -226,15 +226,10 @@ export function InternetPathMapCard({ model }: { model: InternetPathViewModel |
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<CardTitle className="text-base">Internet path map</CardTitle>
|
||||
<p className="text-sm text-muted-foreground mt-0.5 truncate">
|
||||
Основной и текущий путь трафика HomeRouter → Internet
|
||||
</p>
|
||||
</div>
|
||||
<OpsPanel
|
||||
title="Internet path map"
|
||||
description="Основной и текущий путь трафика HomeRouter → Internet"
|
||||
headerRight={
|
||||
<StatusBadge
|
||||
status={
|
||||
model?.pathState === "healthy"
|
||||
@@ -244,9 +239,9 @@ export function InternetPathMapCard({ model }: { model: InternetPathViewModel |
|
||||
: "offline"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
}
|
||||
contentClassName="px-5 pb-4"
|
||||
>
|
||||
{!model && (
|
||||
<div className="h-[240px] rounded-md border border-dashed border-border grid place-items-center text-sm text-muted-foreground">
|
||||
Недостаточно данных для построения маршрута
|
||||
@@ -417,7 +412,6 @@ export function InternetPathMapCard({ model }: { model: InternetPathViewModel |
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</OpsPanel>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ReactNode } from "react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import { DATA_PAGE_CARD_CLASS } from "@/components/data-grids/shared/data-grid-layout"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
@@ -8,11 +8,14 @@ interface DataPageCardProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** Ops list shell — ReUI Frame, not shadcn Card. Preview: https://reui.io/docs/components/base/frame */
|
||||
function DataPageCard({ children, className }: DataPageCardProps) {
|
||||
return (
|
||||
<Card className={cn(DATA_PAGE_CARD_CLASS, className)}>
|
||||
{children}
|
||||
</Card>
|
||||
<Frame dense className={cn("w-full", className)}>
|
||||
<FramePanel className={cn(DATA_PAGE_CARD_CLASS, "p-0")}>
|
||||
{children}
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,25 +6,36 @@ import {
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
} from "@/components/ui/empty"
|
||||
import { IconStack } from "@/components/reui/icon-stack"
|
||||
import { InboxIcon } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
interface EmptyStateProps {
|
||||
icon?: React.ReactNode
|
||||
icon?: ReactNode
|
||||
title: string
|
||||
description?: string
|
||||
action?: React.ReactNode
|
||||
action?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* ReUI Empty + IconStack — Frame-friendly empty-state-14/12.
|
||||
* Preview: https://reui.io/preview/base/empty-state-14 · https://reui.io/preview/base/empty-state-12
|
||||
*/
|
||||
function EmptyState({ icon, title, description, action, className }: EmptyStateProps) {
|
||||
return (
|
||||
<Empty className={cn("border-0 py-16", className)}>
|
||||
<EmptyHeader>
|
||||
{icon && <EmptyMedia variant="icon">{icon}</EmptyMedia>}
|
||||
<Empty className={cn("border-0 bg-transparent py-16", className)}>
|
||||
<EmptyHeader className="gap-5">
|
||||
<EmptyMedia className="mb-0">
|
||||
<IconStack aria-hidden="true" className="h-14 w-12">
|
||||
{icon ?? <InboxIcon strokeWidth={1.9} aria-hidden="true" className="size-5" />}
|
||||
</IconStack>
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{title}</EmptyTitle>
|
||||
{description && <EmptyDescription>{description}</EmptyDescription>}
|
||||
{description ? <EmptyDescription>{description}</EmptyDescription> : null}
|
||||
</EmptyHeader>
|
||||
{action && <EmptyContent>{action}</EmptyContent>}
|
||||
{action ? <EmptyContent>{action}</EmptyContent> : null}
|
||||
</Empty>
|
||||
)
|
||||
}
|
||||
|
||||
+112
-61
@@ -1,10 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useTheme } from "@/components/theme-provider"
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
} from "@/components/ui/avatar"
|
||||
ChevronsUpDownIcon,
|
||||
MonitorIcon,
|
||||
MoonIcon,
|
||||
PaletteIcon,
|
||||
SettingsIcon,
|
||||
SunIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Avatar, AvatarFallback } from "@/components/ui/avatar"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -20,91 +30,132 @@ import {
|
||||
SidebarMenuItem,
|
||||
useSidebar,
|
||||
} from "@/components/ui/sidebar"
|
||||
import {
|
||||
ChevronsUpDownIcon,
|
||||
SparklesIcon,
|
||||
BadgeCheckIcon,
|
||||
CreditCardIcon,
|
||||
BellIcon,
|
||||
LogOutIcon,
|
||||
} from "lucide-react"
|
||||
|
||||
export function NavUser({
|
||||
user,
|
||||
}: {
|
||||
user: {
|
||||
name: string
|
||||
email: string
|
||||
avatar: string
|
||||
}
|
||||
}) {
|
||||
/** Sidebar footer account menu — app-shell-1 NavUser. @see https://reui.io/preview/base/app-shell-1 */
|
||||
|
||||
const THEMES = [
|
||||
{
|
||||
value: "light" as const,
|
||||
label: "Светлая",
|
||||
icon: <SunIcon className="size-3.5" aria-hidden />,
|
||||
},
|
||||
{
|
||||
value: "dark" as const,
|
||||
label: "Тёмная",
|
||||
icon: <MoonIcon className="size-3.5" aria-hidden />,
|
||||
},
|
||||
{
|
||||
value: "system" as const,
|
||||
label: "Системная",
|
||||
icon: <MonitorIcon className="size-3.5" aria-hidden />,
|
||||
},
|
||||
]
|
||||
|
||||
function ThemeSegmentedToggle() {
|
||||
const { theme, setTheme } = useTheme()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
const currentTheme = mounted ? (theme ?? "system") : "system"
|
||||
|
||||
return (
|
||||
<div
|
||||
role="radiogroup"
|
||||
aria-label="Тема"
|
||||
className="bg-muted/60 inline-flex items-center gap-0.5 rounded-full p-0.5"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{THEMES.map(({ value, label, icon }) => {
|
||||
const isActive = currentTheme === value
|
||||
return (
|
||||
<Button
|
||||
key={value}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={isActive}
|
||||
aria-label={label}
|
||||
variant="ghost"
|
||||
size="icon-xs"
|
||||
onClick={() => setTheme(value)}
|
||||
className={cn(
|
||||
"rounded-full",
|
||||
isActive
|
||||
? "bg-background text-foreground shadow-sm"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{icon}
|
||||
</Button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function NavUser() {
|
||||
const { isMobile } = useSidebar()
|
||||
const name = "Оператор"
|
||||
const email = "локальный доступ"
|
||||
const fallback = "ОП"
|
||||
|
||||
return (
|
||||
<SidebarMenu>
|
||||
<SidebarMenuItem>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<SidebarMenuButton size="lg" className="aria-expanded:bg-muted" />
|
||||
<SidebarMenuButton
|
||||
size="lg"
|
||||
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Avatar>
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback>CN</AvatarFallback>
|
||||
<Avatar className="size-8 rounded-lg">
|
||||
<AvatarFallback className="rounded-lg text-xs">{fallback}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="truncate text-xs">{user.email}</span>
|
||||
<span className="truncate font-semibold">{name}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{email}</span>
|
||||
</div>
|
||||
<ChevronsUpDownIcon className="ml-auto size-4" />
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent
|
||||
className="min-w-56 rounded-lg"
|
||||
className="w-(--anchor-width) min-w-56 rounded-lg"
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
align="end"
|
||||
sideOffset={4}
|
||||
>
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuLabel className="p-0 font-normal">
|
||||
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
|
||||
<Avatar>
|
||||
<AvatarImage src={user.avatar} alt={user.name} />
|
||||
<AvatarFallback>CN</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-medium">{user.name}</span>
|
||||
<span className="truncate text-xs">{user.email}</span>
|
||||
</div>
|
||||
<DropdownMenuLabel className="flex items-center gap-2 py-2 font-normal text-foreground">
|
||||
<Avatar className="size-8 rounded-lg">
|
||||
<AvatarFallback className="rounded-lg text-xs">{fallback}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="grid min-w-0 flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">{name}</span>
|
||||
<span className="truncate text-xs text-muted-foreground">{email}</span>
|
||||
</div>
|
||||
</DropdownMenuLabel>
|
||||
</DropdownMenuGroup>
|
||||
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<SparklesIcon />
|
||||
Upgrade to Pro
|
||||
<DropdownMenuItem render={<Link href="/settings" />}>
|
||||
<SettingsIcon aria-hidden />
|
||||
Настройки
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem className="cursor-default focus:bg-transparent">
|
||||
<PaletteIcon aria-hidden />
|
||||
Тема
|
||||
<div className="ml-auto">
|
||||
<ThemeSegmentedToggle />
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem>
|
||||
<BadgeCheckIcon />
|
||||
Account
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<CreditCardIcon />
|
||||
Billing
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<BellIcon />
|
||||
Notifications
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem>
|
||||
<LogOutIcon />
|
||||
Log out
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</SidebarMenuItem>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import type { ReactNode } from "react"
|
||||
import {
|
||||
Frame,
|
||||
FrameDescription,
|
||||
FrameHeader,
|
||||
FramePanel,
|
||||
FrameTitle,
|
||||
} from "@/components/reui/frame"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* Ops section shell — ReUI Frame (settings-3 / chart-15 DNA).
|
||||
* Preview: https://reui.io/preview/base/settings-3 · https://reui.io/docs/components/base/frame
|
||||
*/
|
||||
export function OpsPanel({
|
||||
title,
|
||||
description,
|
||||
headerRight,
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
id,
|
||||
}: {
|
||||
title?: ReactNode
|
||||
description?: ReactNode
|
||||
headerRight?: ReactNode
|
||||
children: ReactNode
|
||||
className?: string
|
||||
contentClassName?: string
|
||||
id?: string
|
||||
}) {
|
||||
return (
|
||||
<Frame dense id={id} className={cn("w-full", className)}>
|
||||
<FramePanel className="flex flex-col gap-0 p-0">
|
||||
{title || description || headerRight ? (
|
||||
<FrameHeader className="gap-1 border-b px-5 py-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
{title ? <FrameTitle className="text-base">{title}</FrameTitle> : null}
|
||||
{description ? <FrameDescription>{description}</FrameDescription> : null}
|
||||
</div>
|
||||
{headerRight ? <div className="shrink-0">{headerRight}</div> : null}
|
||||
</div>
|
||||
</FrameHeader>
|
||||
) : null}
|
||||
<div className={contentClassName}>{children}</div>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
"use client"
|
||||
|
||||
import type { ReactNode } from "react"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { SidebarTrigger } from "@/components/ui/sidebar"
|
||||
import {
|
||||
@@ -8,6 +11,8 @@ import {
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb"
|
||||
import { AppsMenu } from "@/components/apps-menu"
|
||||
import { SystemMonitorPopover } from "@/components/system-monitor-popover"
|
||||
|
||||
interface Crumb {
|
||||
label: string
|
||||
@@ -16,14 +21,15 @@ interface Crumb {
|
||||
|
||||
interface PageHeaderProps {
|
||||
crumbs: Crumb[]
|
||||
actions?: React.ReactNode
|
||||
actions?: ReactNode
|
||||
}
|
||||
|
||||
/** Header chrome — AppsMenu + SystemMonitor; theme in NavUser. @see https://reui.io/preview/base/app-shell-12 */
|
||||
export function PageHeader({ crumbs, actions }: PageHeaderProps) {
|
||||
return (
|
||||
<header className="flex h-14 shrink-0 items-center gap-2 border-b px-4 bg-background/80 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
||||
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4 md:px-6">
|
||||
<SidebarTrigger className="-ml-1" />
|
||||
<Separator orientation="vertical" className="h-4" />
|
||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
{crumbs.map((crumb, i) => (
|
||||
@@ -42,7 +48,11 @@ export function PageHeader({ crumbs, actions }: PageHeaderProps) {
|
||||
))}
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
{actions && <div className="ml-auto flex items-center gap-2">{actions}</div>}
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{actions ? <div className="flex items-center gap-2">{actions}</div> : null}
|
||||
<AppsMenu />
|
||||
<SystemMonitorPopover />
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PageHeader } from "@/components/page-header"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Frame, FramePanel } from "@/components/reui/frame"
|
||||
import type { LucideIcon } from "lucide-react"
|
||||
|
||||
interface PlaceholderPageProps {
|
||||
@@ -19,15 +19,15 @@ export function PlaceholderPage({ section, title, description, Icon }: Placehold
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">{description}</p>
|
||||
</div>
|
||||
<Card>
|
||||
<CardContent className="flex flex-col items-center justify-center py-20 gap-3 text-center">
|
||||
<Frame dense className="w-full">
|
||||
<FramePanel className="flex flex-col items-center justify-center py-20 gap-3 text-center">
|
||||
<Icon className="size-10 text-muted-foreground/30" />
|
||||
<h3 className="text-base font-medium">Скоро</h3>
|
||||
<p className="text-sm text-muted-foreground max-w-xs">
|
||||
Этот раздел будет подключён следующим. Слой данных и эндпоинты бэкенда уже готовы.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</FramePanel>
|
||||
</Frame>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
type IconStackProps = React.ComponentProps<"div">
|
||||
|
||||
function IconStack({ className, children, style, ...props }: IconStackProps) {
|
||||
return (
|
||||
<div
|
||||
data-slot="icon-stack"
|
||||
className={cn(
|
||||
"text-foreground **:data-[slot=icon-stack-layer]:fill-background relative h-20 w-18",
|
||||
className
|
||||
)}
|
||||
style={
|
||||
{
|
||||
"--icon-stack-content-x": "71%",
|
||||
"--icon-stack-content-y": "58%",
|
||||
...style,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
{...props}
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
viewBox="0 0 72 81"
|
||||
fill="none"
|
||||
className="h-full w-full overflow-visible"
|
||||
>
|
||||
<ellipse
|
||||
cx="36"
|
||||
cy="76"
|
||||
rx="30"
|
||||
ry="7"
|
||||
fill="currentColor"
|
||||
fillOpacity="0.055"
|
||||
className="blur-[4px]"
|
||||
/>
|
||||
|
||||
<IconStackLayer opacity="0.4" />
|
||||
<IconStackLayer opacity="0.6" x={13.65} y={6.04} />
|
||||
<IconStackLayer opacity="0.8" x={27.32} y={12.08} active />
|
||||
</svg>
|
||||
|
||||
{children ? (
|
||||
<div
|
||||
data-slot="icon-stack-content"
|
||||
className="text-muted-foreground pointer-events-none absolute top-[var(--icon-stack-content-y)] left-[var(--icon-stack-content-x)] flex -translate-x-1/2 -translate-y-1/2 scale-x-90 -skew-y-26 items-center justify-center"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function IconStackLayer({
|
||||
active = false,
|
||||
opacity,
|
||||
x = 0,
|
||||
y = 0,
|
||||
}: {
|
||||
active?: boolean
|
||||
opacity: string
|
||||
x?: number
|
||||
y?: number
|
||||
}) {
|
||||
return (
|
||||
<g opacity={opacity} transform={`translate(${x} ${y})`}>
|
||||
<path
|
||||
data-slot="icon-stack-layer"
|
||||
d="M42.2538 2.046C41.4408 1.6325 40.3965 1.6677 39.2612 2.2424L7.9616 18.1934C5.3895 19.5039 3.301 23.1064 3.301 26.2322V64.3226C3.301 66.0677 3.9458 67.2943 4.962 67.8199L1.8363 66.229C0.8201 65.7104 0.1753 64.4771 0.1753 62.732V24.6412C0.1753 21.5085 2.2638 17.913 4.8359 16.6024L36.1355 0.6515C37.2778 0.0698 38.322 0.0416 39.128 0.4551L42.2538 2.046Z"
|
||||
stroke="currentColor"
|
||||
strokeOpacity={active ? "0.3" : "0.2"}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
data-slot="icon-stack-layer"
|
||||
d="M42.2545 2.0456C43.2707 2.5643 43.9155 3.7979 43.9155 5.543V43.6337C43.9155 46.7665 41.827 50.3616 39.2549 51.6722L7.9554 67.6235C6.813 68.2052 5.7687 68.2331 4.9628 67.8196C3.9465 67.301 3.3018 66.0673 3.3018 64.3222V26.2318C3.3018 23.0991 5.3903 19.5036 7.9624 18.193L39.2619 2.2421C40.4043 1.6604 41.4486 1.6321 42.2545 2.0456Z"
|
||||
stroke="currentColor"
|
||||
strokeOpacity={active ? "0.3" : "0.2"}
|
||||
strokeWidth="0.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
export { IconStack, type IconStackProps }
|
||||
@@ -0,0 +1,125 @@
|
||||
import { mergeProps } from "@base-ui/react/merge-props"
|
||||
import { useRender } from "@base-ui/react/use-render"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* CSS variable architecture:
|
||||
*
|
||||
* The root owns four variables so every part of the tile stays in proportion
|
||||
* and stays overridable from a single `className`:
|
||||
*
|
||||
* --icon-tile-size tile width/height
|
||||
* --icon-tile-icon-size glyph size applied to child svgs
|
||||
* --icon-tile-radius corner radius (also drives the nested inner card)
|
||||
* --icon-tile-inset gap between the outer ring and the inner card
|
||||
*
|
||||
* The `frame` and `soft` variants paint their inner card with an `::after`
|
||||
* pseudo element instead of a wrapper node. `isolate` makes the root a stacking
|
||||
* context, so the negative z-index pseudo paints above the root background but
|
||||
* below the in-flow icon - no extra DOM, and `render` composition keeps working.
|
||||
*
|
||||
* Tone: `soft` and `solid` derive every fill and border from `currentColor`, so
|
||||
* a single text color class (e.g. `text-success`) retints the whole tile. They
|
||||
* default to `text-primary`; override it to recolor without touching internals.
|
||||
*/
|
||||
const iconTileVariants = cva(
|
||||
[
|
||||
"relative inline-flex shrink-0 items-center justify-center align-middle",
|
||||
"size-(--icon-tile-size) rounded-(--icon-tile-radius)",
|
||||
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=size-])]:size-(--icon-tile-icon-size)",
|
||||
],
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
/** Plain bordered surface. The quiet default for list rows and toolbars. */
|
||||
outline: "border border-border bg-background dark:bg-input/32",
|
||||
/** Raised muted fill with a background-colored ring. Reads as a physical chip. */
|
||||
elevated:
|
||||
"border-2 border-background bg-muted text-accent-foreground shadow-[0_1px_3px_0_rgb(0_0_0/0.14)] dark:border",
|
||||
/**
|
||||
* Tinted double container: an opacity-filled outer ring with no border
|
||||
* around a bordered inner card, all derived from `currentColor`. The
|
||||
* quiet, colorful sibling of `frame`. Retint with a text color class.
|
||||
*/
|
||||
soft: [
|
||||
"isolate p-(--icon-tile-inset) text-primary bg-current/10",
|
||||
"after:absolute after:-z-10 after:inset-(--icon-tile-inset)",
|
||||
"after:rounded-[calc(var(--icon-tile-radius)-var(--icon-tile-inset))]",
|
||||
"after:border after:border-current/20 after:bg-current/5",
|
||||
],
|
||||
/** Filled tone with a contrasting glyph. Retint with `bg-*` + a text color. */
|
||||
solid: "bg-primary text-primary-foreground",
|
||||
/** Double container - a muted ring around an inset card, matching Frame. */
|
||||
frame: [
|
||||
"isolate border border-border bg-muted/50 p-(--icon-tile-inset)",
|
||||
"after:absolute after:-z-10 after:inset-(--icon-tile-inset)",
|
||||
"after:rounded-[calc(var(--icon-tile-radius)-var(--icon-tile-inset))]",
|
||||
"after:border after:border-border after:bg-card after:shadow-xs",
|
||||
],
|
||||
},
|
||||
size: {
|
||||
xs: "[--icon-tile-size:--spacing(6)] [--icon-tile-icon-size:--spacing(3.5)] [--icon-tile-inset:--spacing(0.5)]",
|
||||
sm: "[--icon-tile-size:--spacing(8)] [--icon-tile-icon-size:--spacing(4)] [--icon-tile-inset:--spacing(0.5)]",
|
||||
default:
|
||||
"[--icon-tile-size:--spacing(10)] [--icon-tile-icon-size:--spacing(4.5)] [--icon-tile-inset:--spacing(0.75)]",
|
||||
lg: "[--icon-tile-size:--spacing(12)] [--icon-tile-icon-size:--spacing(5.5)] [--icon-tile-inset:--spacing(0.75)]",
|
||||
xl: "[--icon-tile-size:--spacing(14)] [--icon-tile-icon-size:--spacing(7)] [--icon-tile-inset:--spacing(1)]",
|
||||
},
|
||||
/**
|
||||
* `default`: active style radius. `full`: circular.
|
||||
* The plain value is the fallback outside a style scope; the `style-*`
|
||||
* tokens win by specificity inside one, and survive the registry
|
||||
* transform as the single resolved value per generated style.
|
||||
*
|
||||
* Each style token is clamped to a fraction of the tile. A flat radius
|
||||
* is a circle once it reaches half the box, so the soft styles used to
|
||||
* render `xs` (24px) and `sm` (32px) as plain circles and swallow the
|
||||
* `full` variant's meaning. Clamping keeps one corner ratio at every
|
||||
* size instead, so a tile still reads as the same shape when it scales.
|
||||
* Luma and Rhea take the gentler quarter ratio; Maia stays at a third.
|
||||
*/
|
||||
radius: {
|
||||
default:
|
||||
"[--icon-tile-radius:min(var(--radius-md),calc(var(--icon-tile-size)/3))] [--icon-tile-radius:min(var(--radius-md),calc(var(--icon-tile-size)/3))]",
|
||||
full: "[--icon-tile-radius:calc(infinity*1px)]",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "outline",
|
||||
size: "default",
|
||||
radius: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
interface IconTileProps extends useRender.ComponentProps<"span"> {
|
||||
variant?: VariantProps<typeof iconTileVariants>["variant"]
|
||||
size?: VariantProps<typeof iconTileVariants>["size"]
|
||||
radius?: VariantProps<typeof iconTileVariants>["radius"]
|
||||
}
|
||||
|
||||
function IconTile({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
radius = "default",
|
||||
render,
|
||||
...props
|
||||
}: IconTileProps) {
|
||||
const defaultProps = {
|
||||
"data-slot": "icon-tile",
|
||||
"data-variant": variant,
|
||||
"data-size": size,
|
||||
className: cn(iconTileVariants({ variant, size, radius, className })),
|
||||
}
|
||||
|
||||
return useRender({
|
||||
defaultTagName: "span",
|
||||
render,
|
||||
props: mergeProps<"span">(defaultProps, props),
|
||||
})
|
||||
}
|
||||
|
||||
export { IconTile, iconTileVariants, type IconTileProps }
|
||||
@@ -0,0 +1,252 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useMemo, useState, type ReactNode } from "react"
|
||||
import { Activity, Filter, HeartPulse, Server } from "lucide-react"
|
||||
|
||||
import { Badge } from "@/components/reui/badge"
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { useDataSource } from "@/lib/data-source"
|
||||
import { filters, pingProbes, servers } from "@/lib/data"
|
||||
import type { SidebarCountsDto } from "@/lib/sidebar-badges"
|
||||
|
||||
type MonitorMetric = {
|
||||
id: string
|
||||
label: string
|
||||
value: string
|
||||
unit: string
|
||||
percent: number
|
||||
icon: ReactNode
|
||||
tone: "success" | "warning" | "destructive" | "info"
|
||||
alert: boolean
|
||||
}
|
||||
|
||||
type HealthDto = {
|
||||
status?: string
|
||||
}
|
||||
|
||||
function toneColor(tone: MonitorMetric["tone"]) {
|
||||
switch (tone) {
|
||||
case "success":
|
||||
return "var(--color-success)"
|
||||
case "warning":
|
||||
return "var(--color-warning)"
|
||||
case "destructive":
|
||||
return "var(--color-destructive)"
|
||||
default:
|
||||
return "var(--color-info)"
|
||||
}
|
||||
}
|
||||
|
||||
function MetricBar({ percent, color }: { percent: number; color: string }) {
|
||||
return (
|
||||
<div className="bg-muted h-1 w-full overflow-hidden rounded-full">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{
|
||||
width: `${Math.min(100, Math.max(0, percent))}%`,
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MetricCell({ metric }: { metric: MonitorMetric }) {
|
||||
const color = toneColor(metric.tone)
|
||||
return (
|
||||
<div className="flex flex-col gap-2 p-3">
|
||||
<div className="flex items-center justify-between gap-1">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<div
|
||||
className="flex size-5 shrink-0 items-center justify-center rounded-md"
|
||||
style={{ backgroundColor: `${color}18`, color }}
|
||||
>
|
||||
{metric.icon}
|
||||
</div>
|
||||
<span className="text-muted-foreground truncate text-[11px]">{metric.label}</span>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs font-semibold tabular-nums" style={{ color }}>
|
||||
{metric.value}
|
||||
<span className="text-muted-foreground ml-0.5 text-[10px] font-normal">{metric.unit}</span>
|
||||
</span>
|
||||
</div>
|
||||
<MetricBar percent={metric.percent} color={color} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Live system monitor popover — app-shell-7. @see https://reui.io/preview/base/app-shell-7 */
|
||||
export function SystemMonitorPopover() {
|
||||
const { mode, backendUrl } = useDataSource()
|
||||
const [healthOk, setHealthOk] = useState<boolean | null>(null)
|
||||
const [counts, setCounts] = useState<SidebarCountsDto | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "live") {
|
||||
setHealthOk(true)
|
||||
setCounts({
|
||||
servers: servers.length,
|
||||
filterRules: filters.length,
|
||||
uptimeProbes: pingProbes.length,
|
||||
uptimeSpeedProbes: 0,
|
||||
monitoringItems: pingProbes.length,
|
||||
recursiveRoutes: 0,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
const load = async () => {
|
||||
const base = backendUrl.replace(/\/$/, "")
|
||||
try {
|
||||
const [hRes, cRes] = await Promise.all([
|
||||
fetch(`${base}/health`),
|
||||
fetch(`${base}/api/sidebar-counts`),
|
||||
])
|
||||
if (cancelled) return
|
||||
if (hRes.ok) {
|
||||
const json = (await hRes.json()) as HealthDto
|
||||
setHealthOk(json.status === "ok")
|
||||
} else {
|
||||
setHealthOk(false)
|
||||
}
|
||||
if (cRes.ok) {
|
||||
setCounts((await cRes.json()) as SidebarCountsDto)
|
||||
} else {
|
||||
setCounts(null)
|
||||
}
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setHealthOk(false)
|
||||
setCounts(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
void load()
|
||||
const id = window.setInterval(load, 30_000)
|
||||
return () => {
|
||||
cancelled = true
|
||||
window.clearInterval(id)
|
||||
}
|
||||
}, [mode, backendUrl])
|
||||
|
||||
const serversCount = counts?.servers ?? 0
|
||||
const filtersCount = counts?.filterRules ?? 0
|
||||
const monitoringCount = counts?.monitoringItems ?? 0
|
||||
const apiOk = healthOk === true
|
||||
|
||||
const metrics = useMemo<MonitorMetric[]>(
|
||||
() => [
|
||||
{
|
||||
id: "api",
|
||||
label: "API",
|
||||
value: healthOk == null ? "…" : apiOk ? "OK" : "—",
|
||||
unit: "",
|
||||
percent: apiOk ? 100 : 20,
|
||||
icon: <Activity className="size-3" aria-hidden />,
|
||||
tone: apiOk ? "success" : "destructive",
|
||||
alert: healthOk === false,
|
||||
},
|
||||
{
|
||||
id: "servers",
|
||||
label: "Серверы",
|
||||
value: String(serversCount),
|
||||
unit: "шт.",
|
||||
percent: Math.min(100, Math.max(12, serversCount * 8)),
|
||||
icon: <Server className="size-3" aria-hidden />,
|
||||
tone: serversCount > 0 ? "success" : "warning",
|
||||
alert: false,
|
||||
},
|
||||
{
|
||||
id: "filters",
|
||||
label: "Фильтры",
|
||||
value: String(filtersCount),
|
||||
unit: "шт.",
|
||||
percent: Math.min(100, Math.max(12, filtersCount * 5)),
|
||||
icon: <Filter className="size-3" aria-hidden />,
|
||||
tone: "info",
|
||||
alert: false,
|
||||
},
|
||||
{
|
||||
id: "uptime",
|
||||
label: "Мониторинг",
|
||||
value: String(monitoringCount),
|
||||
unit: "шт.",
|
||||
percent: Math.min(100, Math.max(12, monitoringCount * 8)),
|
||||
icon: <HeartPulse className="size-3" aria-hidden />,
|
||||
tone: monitoringCount > 0 ? "success" : "warning",
|
||||
alert: false,
|
||||
},
|
||||
],
|
||||
[apiOk, filtersCount, healthOk, monitoringCount, serversCount],
|
||||
)
|
||||
|
||||
const spiking = metrics.some((m) => m.alert)
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Монитор системы"
|
||||
className={cn(
|
||||
"relative inline-flex h-8 items-center gap-1.5 rounded-md border px-2 transition-colors outline-none",
|
||||
"border-border hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring",
|
||||
)}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<span className="relative flex size-3.5 items-center justify-center">
|
||||
<Activity
|
||||
aria-hidden
|
||||
className={cn(
|
||||
"size-3.5 transition-colors",
|
||||
spiking ? "text-destructive" : "text-muted-foreground",
|
||||
)}
|
||||
/>
|
||||
{spiking ? (
|
||||
<span className="bg-destructive/25 absolute inset-0 animate-ping rounded-full" aria-hidden />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="text-foreground hidden text-xs font-medium sm:inline">Система</span>
|
||||
<Badge
|
||||
variant={spiking ? "destructive-light" : "success-light"}
|
||||
size="xs"
|
||||
className="h-4 px-1.5 text-[10px]"
|
||||
>
|
||||
{spiking ? "Внимание" : "Норма"}
|
||||
</Badge>
|
||||
</PopoverTrigger>
|
||||
|
||||
<PopoverContent align="end" sideOffset={8} className="flex w-80 flex-col gap-0! p-0!">
|
||||
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
|
||||
<span className="text-foreground text-xs font-medium">Монитор MikrotikManager</span>
|
||||
<span className="text-muted-foreground text-[11px] tabular-nums">
|
||||
{new Date().toLocaleTimeString("ru-RU")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2">
|
||||
{metrics.map((metric, i) => (
|
||||
<div
|
||||
key={metric.id}
|
||||
className={cn(
|
||||
i % 2 === 1 && "border-border border-l",
|
||||
i >= 2 && "border-border border-t",
|
||||
)}
|
||||
>
|
||||
<MetricCell metric={metric} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-border text-muted-foreground border-t px-3 py-2 text-[11px]">
|
||||
Источник:{" "}
|
||||
<span className="text-foreground font-medium">
|
||||
{mode === "live" ? "API" : "мок"}
|
||||
</span>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { AlertDialog as AlertDialogPrimitive } from "@base-ui/react/alert-dialog"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
function AlertDialog({ ...props }: AlertDialogPrimitive.Root.Props) {
|
||||
return <AlertDialogPrimitive.Root data-slot="alert-dialog" {...props} />
|
||||
}
|
||||
|
||||
function AlertDialogTrigger({ ...props }: AlertDialogPrimitive.Trigger.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Trigger data-slot="alert-dialog-trigger" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogPortal({ ...props }: AlertDialogPrimitive.Portal.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Portal data-slot="alert-dialog-portal" {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: AlertDialogPrimitive.Backdrop.Props) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Backdrop
|
||||
data-slot="alert-dialog-overlay"
|
||||
className={cn(
|
||||
"fixed inset-0 isolate z-50 bg-black/10 duration-100 supports-backdrop-filter:backdrop-blur-xs data-open:animate-in data-open:fade-in-0 data-closed:animate-out data-closed:fade-out-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogContent({
|
||||
className,
|
||||
size = "default",
|
||||
...props
|
||||
}: AlertDialogPrimitive.Popup.Props & {
|
||||
size?: "default" | "sm"
|
||||
}) {
|
||||
return (
|
||||
<AlertDialogPortal>
|
||||
<AlertDialogOverlay />
|
||||
<AlertDialogPrimitive.Popup
|
||||
data-slot="alert-dialog-content"
|
||||
data-size={size}
|
||||
className={cn(
|
||||
"group/alert-dialog-content fixed top-1/2 left-1/2 z-50 grid w-full -translate-x-1/2 -translate-y-1/2 gap-4 rounded-xl bg-popover p-4 text-popover-foreground ring-1 ring-foreground/10 duration-100 outline-none data-[size=default]:max-w-xs data-[size=sm]:max-w-xs data-[size=default]:sm:max-w-sm data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</AlertDialogPortal>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogHeader({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-header"
|
||||
className={cn(
|
||||
"grid grid-rows-[auto_1fr] place-items-center gap-1.5 text-center has-data-[slot=alert-dialog-media]:grid-rows-[auto_auto_1fr] has-data-[slot=alert-dialog-media]:gap-x-4 sm:group-data-[size=default]/alert-dialog-content:place-items-start sm:group-data-[size=default]/alert-dialog-content:text-left sm:group-data-[size=default]/alert-dialog-content:has-data-[slot=alert-dialog-media]:grid-rows-[auto_1fr]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogFooter({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-footer"
|
||||
className={cn(
|
||||
"-mx-4 -mb-4 flex flex-col-reverse gap-2 rounded-b-xl border-t bg-muted/50 p-4 group-data-[size=sm]/alert-dialog-content:grid group-data-[size=sm]/alert-dialog-content:grid-cols-2 sm:flex-row sm:justify-end",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogMedia({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="alert-dialog-media"
|
||||
className={cn(
|
||||
"mb-2 inline-flex size-10 items-center justify-center rounded-md bg-muted sm:group-data-[size=default]/alert-dialog-content:row-span-2 *:[svg:not([class*='size-'])]:size-6",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Title>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Title
|
||||
data-slot="alert-dialog-title"
|
||||
className={cn(
|
||||
"font-heading text-base font-medium sm:group-data-[size=default]/alert-dialog-content:group-has-data-[slot=alert-dialog-media]/alert-dialog-content:col-start-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof AlertDialogPrimitive.Description>) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Description
|
||||
data-slot="alert-dialog-description"
|
||||
className={cn(
|
||||
"text-sm text-balance text-muted-foreground md:text-pretty *:[a]:underline *:[a]:underline-offset-3 *:[a]:hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogAction({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
return (
|
||||
<Button
|
||||
data-slot="alert-dialog-action"
|
||||
className={cn(className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function AlertDialogCancel({
|
||||
className,
|
||||
variant = "outline",
|
||||
size = "default",
|
||||
...props
|
||||
}: AlertDialogPrimitive.Close.Props &
|
||||
Pick<React.ComponentProps<typeof Button>, "variant" | "size">) {
|
||||
return (
|
||||
<AlertDialogPrimitive.Close
|
||||
data-slot="alert-dialog-cancel"
|
||||
className={cn(className)}
|
||||
render={<Button variant={variant} size={size} />}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogMedia,
|
||||
AlertDialogOverlay,
|
||||
AlertDialogPortal,
|
||||
AlertDialogTitle,
|
||||
AlertDialogTrigger,
|
||||
}
|
||||
@@ -70,8 +70,8 @@ function SheetContent({
|
||||
/>
|
||||
}
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
<XIcon aria-hidden="true" />
|
||||
<span className="sr-only">Закрыть</span>
|
||||
</SheetPrimitive.Close>
|
||||
)}
|
||||
</SheetPrimitive.Popup>
|
||||
|
||||
@@ -27,7 +27,7 @@ import { PanelLeftIcon } from "lucide-react"
|
||||
|
||||
const SIDEBAR_COOKIE_NAME = "sidebar_state"
|
||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7
|
||||
const SIDEBAR_WIDTH = "16rem"
|
||||
const SIDEBAR_WIDTH = "240px"
|
||||
const SIDEBAR_WIDTH_MOBILE = "18rem"
|
||||
const SIDEBAR_WIDTH_ICON = "3rem"
|
||||
const SIDEBAR_KEYBOARD_SHORTCUT = "b"
|
||||
@@ -189,8 +189,8 @@ function Sidebar({
|
||||
side={side}
|
||||
>
|
||||
<SheetHeader className="sr-only">
|
||||
<SheetTitle>Sidebar</SheetTitle>
|
||||
<SheetDescription>Displays the mobile sidebar.</SheetDescription>
|
||||
<SheetTitle>Боковое меню</SheetTitle>
|
||||
<SheetDescription>Навигация по приложению</SheetDescription>
|
||||
</SheetHeader>
|
||||
<div className="flex h-full w-full flex-col">{children}</div>
|
||||
</SheetContent>
|
||||
@@ -247,7 +247,7 @@ function SidebarTrigger({
|
||||
onClick,
|
||||
...props
|
||||
}: React.ComponentProps<typeof Button>) {
|
||||
const { toggleSidebar } = useSidebar()
|
||||
const { toggleSidebar, open, openMobile, isMobile } = useSidebar()
|
||||
|
||||
return (
|
||||
<Button
|
||||
@@ -255,6 +255,9 @@ function SidebarTrigger({
|
||||
data-slot="sidebar-trigger"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
aria-label="Боковое меню"
|
||||
aria-expanded={isMobile ? openMobile : open}
|
||||
className={cn(className)}
|
||||
onClick={(event) => {
|
||||
onClick?.(event)
|
||||
@@ -262,8 +265,8 @@ function SidebarTrigger({
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
<PanelLeftIcon />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
<PanelLeftIcon aria-hidden="true" />
|
||||
<span className="sr-only">Боковое меню</span>
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -275,10 +278,10 @@ function SidebarRail({ className, ...props }: React.ComponentProps<"button">) {
|
||||
<button
|
||||
data-sidebar="rail"
|
||||
data-slot="sidebar-rail"
|
||||
aria-label="Toggle Sidebar"
|
||||
aria-label="Боковое меню"
|
||||
tabIndex={-1}
|
||||
onClick={toggleSidebar}
|
||||
title="Toggle Sidebar"
|
||||
title="Боковое меню"
|
||||
className={cn(
|
||||
"absolute inset-y-0 z-20 hidden w-4 transition-all ease-linear group-data-[side=left]:-right-4 group-data-[side=right]:left-0 after:absolute after:inset-y-0 after:start-1/2 after:w-[2px] hover:after:bg-sidebar-border sm:flex ltr:-translate-x-1/2 rtl:-translate-x-1/2",
|
||||
"in-data-[side=left]:cursor-w-resize in-data-[side=right]:cursor-e-resize",
|
||||
|
||||
Reference in New Issue
Block a user