diff --git a/apps/web/src/components/analytics/monitoring-health-card.tsx b/apps/web/src/components/analytics/monitoring-health-card.tsx index aac7c89..c884d7e 100644 --- a/apps/web/src/components/analytics/monitoring-health-card.tsx +++ b/apps/web/src/components/analytics/monitoring-health-card.tsx @@ -1,5 +1,4 @@ -import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell' -import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric' +import { DonutBreakdownCard } from '@/components/patterns/donut-breakdown-card' import { readinessBreakdown } from '@/lib/metrics' import type { ReadyStatus } from '@/queries/monitoring' @@ -13,21 +12,25 @@ export function MonitoringHealthCard({ loading?: boolean }) { const slices = readinessBreakdown(ready, healthOk) - const total = slices.reduce((sum, slice) => sum + slice.count, 0) + + if (loading) { + return ( + + ) + } return ( - - {loading ? ( -
- Загрузка… -
- ) : ( - - )} -
+ description="GET /v1/health · GET /v1/ready" + slices={slices} + centerLabel="Проверки" + badge={healthOk ? 'API OK' : undefined} + /> ) } diff --git a/apps/web/src/components/analytics/network-overview-analytics-card.tsx b/apps/web/src/components/analytics/network-overview-analytics-card.tsx index 51968e5..868074d 100644 --- a/apps/web/src/components/analytics/network-overview-analytics-card.tsx +++ b/apps/web/src/components/analytics/network-overview-analytics-card.tsx @@ -1,6 +1,5 @@ -import { AnalyticsCardShell } from '@/components/analytics/analytics-card-shell' -import { AnalyticsKpiRow } from '@/components/analytics/analytics-kpi-row' -import { ChartDonutMetric } from '@/components/analytics/chart-donut-metric' +import { DonutBreakdownCard } from '@/components/patterns/donut-breakdown-card' +import { SegmentedProgressCard } from '@/components/patterns/segmented-progress-card' import { peerSessionBreakdown } from '@/lib/metrics' import { aggregateNetworkMetrics } from '@/queries/overview' import type { PeerRow, SpeakerRow } from '@/types/api' @@ -16,51 +15,44 @@ export function NetworkOverviewAnalyticsCard({ }) { const net = aggregateNetworkMetrics(peers, speakers) const slices = peerSessionBreakdown(peers) - const total = slices.reduce((sum, slice) => sum + slice.count, 0) + const speakersPct = + net.speakersTotal > 0 ? Math.round((net.speakersOnline / net.speakersTotal) * 100) : 0 + + if (loading) { + return ( + + ) + } return ( - - 0 ? 'down' : 'up', - label: net.peersMismatch > 0 ? `${net.peersMismatch} расхождений` : 'сессии в норме', - tone: net.peersMismatch > 0 ? 'warning' : 'success', - }, - }, - { - label: 'Спикеры в сети', - value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`, - delta: { - direction: net.speakersOnline < net.speakersTotal ? 'down' : 'up', - label: - net.speakersOnline < net.speakersTotal - ? `${net.speakersTotal - net.speakersOnline} не в сети` - : 'все в сети', - tone: net.speakersOnline < net.speakersTotal ? 'warning' : 'success', - }, - }, - { - label: 'Пиры всего', - value: loading ? '—' : String(net.peersTotal), - delta: { direction: 'neutral', label: 'в каталоге', tone: 'muted' }, - }, - ]} +
+ 0 ? `${net.peersMismatch} расхождений` : 'в норме'} /> - {loading ? ( -
- Загрузка… -
- ) : ( - - )} - + +
) } diff --git a/apps/web/src/components/blocks/app-shell-7/components/app-header.tsx b/apps/web/src/components/blocks/app-shell-7/components/app-header.tsx new file mode 100644 index 0000000..3d2d858 --- /dev/null +++ b/apps/web/src/components/blocks/app-shell-7/components/app-header.tsx @@ -0,0 +1,220 @@ +"use client" + +import { useState } from "react" + +import { cn } from "@evobgp/ui/lib/utils" +import { + Breadcrumb, + BreadcrumbItem, + BreadcrumbList, + BreadcrumbSeparator, +} from "@evobgp/ui/components/breadcrumb" +import { Button } from "@evobgp/ui/components/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@evobgp/ui/components/dropdown-menu" +import { SidebarTrigger } from "@evobgp/ui/components/sidebar" +import { APPS, ENVIRONMENTS, WORKSPACES } from "./data" +import { Logo } from "./logo" +import { SystemStats } from "./system-stats" +import { ChevronsUpDownIcon, CheckIcon, PlusIcon } from "lucide-react" + +// ── Org Switcher ── + +function OrgSwitcher() { + const [activeOrg, setActiveOrg] = useState(WORKSPACES[0]) + + return ( + + + } + > + + {activeOrg.name} + + + + + Organizations + + {WORKSPACES.map((ws) => ( + setActiveOrg(ws)} + className={cn(activeOrg.id === ws.id && "bg-accent")} + > + +
+ {ws.name} + + {ws.tier} + +
+ {activeOrg.id === ws.id && ( +
+ ))} +
+ + + +
+
+
+ ) +} + +// ── App Switcher ── + +function AppSwitcher() { + const [activeApp, setActiveApp] = useState( + APPS.find((a) => a.isActive) ?? APPS[0] + ) + + return ( + + + } + > + {activeApp.name} + {activeApp.name.split(" ")[0]} + + + + + Applications + + {APPS.map((app) => ( + setActiveApp(app)} + className={cn(activeApp.id === app.id && "bg-accent")} + > + {app.icon} + {app.name} + {activeApp.id === app.id && ( + + ))} + + + + + + + + ) +} + +// ── Environment Switcher ── + +function EnvironmentSwitcher() { + const [activeEnv, setActiveEnv] = useState( + ENVIRONMENTS.find((e) => e.isActive) ?? ENVIRONMENTS[0] + ) + + return ( + + + } + > + {activeEnv.name} + + + + + Environments + + {ENVIRONMENTS.map((env) => ( + setActiveEnv(env)} + className={cn(activeEnv.id === env.id && "bg-accent")} + > + {env.icon} + {env.name} + {activeEnv.id === env.id && ( + + ))} + + + + + ) +} + +// ── Site Header ── + +export function AppHeader() { + return ( +
+
+ {/* Mobile sidebar trigger */} + + + {/* Logo */} +
+ +
+ + + + + + + + / + + + + + + / + + + + + + + + {/* Right side */} +
+ +
+
+
+ ) +} \ No newline at end of file diff --git a/apps/web/src/components/blocks/app-shell-7/components/app-shell.tsx b/apps/web/src/components/blocks/app-shell-7/components/app-shell.tsx new file mode 100644 index 0000000..672e1ac --- /dev/null +++ b/apps/web/src/components/blocks/app-shell-7/components/app-shell.tsx @@ -0,0 +1,43 @@ +import { type CSSProperties } from "react" + +import { cn } from "@evobgp/ui/lib/utils" +import { SidebarInset, SidebarProvider } from "@evobgp/ui/components/sidebar" + +import { AppHeader } from "./app-header" +import { AppSidebar } from "./app-sidebar" + +export function AppShell() { + return ( + + {/* Header */} + +
+ + +
+
+
+
+
+
+
+
+ +
+ + ) +} \ No newline at end of file diff --git a/apps/web/src/components/blocks/app-shell-7/components/app-sidebar.tsx b/apps/web/src/components/blocks/app-shell-7/components/app-sidebar.tsx new file mode 100644 index 0000000..13ef614 --- /dev/null +++ b/apps/web/src/components/blocks/app-shell-7/components/app-sidebar.tsx @@ -0,0 +1,51 @@ +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarHeader, + useSidebar, +} from "@evobgp/ui/components/sidebar" + +import { NEWS_ARTICLES } from "./data" +import { NavMain } from "./nav-main" +import { NavUser } from "./nav-user" +import { SearchForm } from "./search-form" +import { SidebarNews } from "./sidebar-news" +import { SidebarRailToggle } from "./sidebar-rail-toggle" + +// ── App Sidebar ── + +export function AppSidebar() { + const { isMobile } = useSidebar() + + return ( + + {/* Header */} + +
+ +
+
+ + {/* Sidebar */} + + + +
+ +
+
+ + {/* Footer */} + {/* customize: mt-2 keeps a fixed gap above the footer so the news card never butts against it when the viewport shrinks and the content scrolls */} + + + + + {!isMobile && } +
+ ) +} \ No newline at end of file diff --git a/apps/web/src/components/blocks/app-shell-7/components/data.tsx b/apps/web/src/components/blocks/app-shell-7/components/data.tsx new file mode 100644 index 0000000..f6b9990 --- /dev/null +++ b/apps/web/src/components/blocks/app-shell-7/components/data.tsx @@ -0,0 +1,385 @@ +import { type ComponentType, type ReactNode } from "react" +import { GlobeIcon, ShieldIcon, SmartphoneIcon, RocketIcon, FlaskConicalIcon, CodeIcon, LayoutDashboardIcon, UsersIcon, Building2Icon, ShieldCheckIcon, WebhookIcon, KeyRoundIcon, CpuIcon, MonitorIcon, MemoryStickIcon, WifiIcon } from "lucide-react" + +// ── Types ── + +export type NavChild = { + id: string + label: string + isActive?: boolean +} + +export type NavItem = { + id: string + label: string + icon: ReactNode + badge?: string | number + isActive?: boolean + children?: NavChild[] +} + +export type Workspace = { + id: string + name: string + tier: string + Logo: ComponentType<{ className?: string }> +} + +export type App = { + id: string + name: string + icon: ReactNode + isActive?: boolean +} + +export type Environment = { + id: string + name: string + icon: ReactNode + isActive?: boolean +} + +export type NewsArticle = { + href: string + title: string + summary: string + image: string +} + +export type ResourceMetric = { + id: string + label: string + unit: string + icon: ReactNode + seedRange: [number, number] + color: string + spikeThreshold: number +} + +export type Agent = { + id: string + name: string + memoryMb: number + color: string +} + +// ── Logos ── + +// Acme: indigo → violet → fuchsia diagonal sweep +function AcmeLogo({ className }: { className?: string }) { + return ( + + ) +} + +// Starter: rose → orange → amber → lime horizontal sweep +function StarterLogo({ className }: { className?: string }) { + return ( + + ) +} + +// Enterprise: sky → blue → indigo vertical sweep +function EnterpriseLogo({ className }: { className?: string }) { + return ( + + ) +} + +// ── Data ── + +export const USER = { + name: "Nick Bold", + email: "nick@reui.io", + initials: "NB", + avatar: + "https://images.unsplash.com/photo-1543299750-19d1d6297053?w=96&h=96&dpr=2&q=80", +} as const + +export const WORKSPACES: Workspace[] = [ + { id: "acme", name: "Acme Inc", tier: "Pro", Logo: AcmeLogo }, + { id: "starter", name: "Starter Kit", tier: "Free", Logo: StarterLogo }, + { + id: "enterprise", + name: "Enterprise", + tier: "Enterprise", + Logo: EnterpriseLogo, + }, +] + +export const APPS: App[] = [ + { + id: "web-app", + name: "Web App", + isActive: true, + icon: ( +