Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c1132cbe19 | ||
|
|
b28ad88b22 | ||
|
|
b871d62de6 | ||
|
|
4d83b8d673 | ||
|
|
2820cff988 | ||
|
|
54b7ea3bd5 | ||
|
|
57bcfcd9e1 | ||
|
|
1317a73e9a | ||
|
|
039d2f3dd9 | ||
|
|
54a0b5b966 | ||
|
|
1639ba40f3 | ||
|
|
26a96bc824 | ||
|
|
4fc5c96e63 | ||
|
|
3859983ae5 | ||
|
|
aa1779170a |
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"pid": 43636,
|
"pid": 51184,
|
||||||
"version": "0.9.9",
|
"version": "0.9.9",
|
||||||
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
|
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
|
||||||
"startedAt": 1783570299043
|
"startedAt": 1784215907952
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":0,"jobs":0,"loading":true,"modulesError":false,"jobsError":false},"timestamp":1783613834659}
|
||||||
|
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783613834740}
|
||||||
|
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":0,"jobs":0,"loading":true,"modulesError":false,"jobsError":false},"timestamp":1783613854903}
|
||||||
|
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783613854967}
|
||||||
|
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":0,"jobs":0,"loading":true,"modulesError":false,"jobsError":false},"timestamp":1783613897698}
|
||||||
|
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783613897769}
|
||||||
|
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783614164222}
|
||||||
|
{"sessionId":"ce85d7","runId":"pre-fix","hypothesisId":"C","location":"schedule.tsx:mount","message":"schedule page data loaded","data":{"modules":4,"jobs":0,"loading":false,"modulesError":false,"jobsError":false},"timestamp":1783614181455}
|
||||||
@@ -22,9 +22,12 @@ alwaysApply: false
|
|||||||
|------|------|------------|
|
|------|------|------------|
|
||||||
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
|
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
|
||||||
| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` |
|
| ReUI enterprise | `apps/web/src/components/reui/` | output `shadcn add @reui/*` |
|
||||||
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, SectionCards, LoadingButton |
|
| Shared обёртки | `apps/web/src/components/` | PageHeader, QueryState, ConfirmDialog, StatusBadge, LoadingButton |
|
||||||
|
| ReUI kit | `apps/web/src/components/reui-kit/` | ResourcePage, KpiStatGrid, OpsDashboard, SettingsShell |
|
||||||
| Роуты | `apps/web/src/routes/` | TanStack Router (file-based) |
|
| Роуты | `apps/web/src/routes/` | TanStack Router (file-based) |
|
||||||
|
|
||||||
|
**Design contract:** [`docs/ui-design-contract.md`](../../docs/ui-design-contract.md). Surface: **frame**. KPI: [stats-12](https://reui.io/preview/base/stats-12). Lists: [data-grid-filtering-2](https://reui.io/preview/base/data-grid-filtering-2).
|
||||||
|
|
||||||
Тема: `packages/ui/src/styles/globals.css`. CLI из `apps/web`: `pnpm dlx shadcn@latest add <component>`.
|
Тема: `packages/ui/src/styles/globals.css`. CLI из `apps/web`: `pnpm dlx shadcn@latest add <component>`.
|
||||||
|
|
||||||
## Правила
|
## Правила
|
||||||
@@ -45,8 +48,8 @@ alwaysApply: false
|
|||||||
**WEB-05** | MUST | Формы — `react-hook-form` + Zod; через `FormField`/`Form` обёртки.
|
**WEB-05** | MUST | Формы — `react-hook-form` + Zod; через `FormField`/`Form` обёртки.
|
||||||
*Проверка:* https://ui.shadcn.com/docs/components/form
|
*Проверка:* https://ui.shadcn.com/docs/components/form
|
||||||
|
|
||||||
**WEB-06** | MUST | Сложные data-списки — ReUI `DataGridCard` (ReUI data-grid, не shadcn Data Table). Простые списки — shadcn `Table`.
|
**WEB-06** | MUST | Сложные data-списки — Frame + ReUI DataGrid через `reui-kit/ResourcePage` или `DataGridSection` (не Card shell, не shadcn Data Table). Surface lock: `UI_SURFACE = 'frame'` (`lib/ui-surface.ts`). Простые списки — shadcn `Table`.
|
||||||
*Проверка:* `@/components/reui/data-grid` или `@evobgp/ui/components/table`.
|
*Проверка:* `@/components/reui-kit`, `@/components/reui/data-grid`, `docs/ui-design-contract.md`.
|
||||||
|
|
||||||
**WEB-07** | MUST | Toast — `sonner` (`Toaster` в `main.tsx`); `toast.success/error/message` из `sonner`.
|
**WEB-07** | MUST | Toast — `sonner` (`Toaster` в `main.tsx`); `toast.success/error/message` из `sonner`.
|
||||||
*Проверка:* https://ui.shadcn.com/docs/components/sonner
|
*Проверка:* https://ui.shadcn.com/docs/components/sonner
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ Thumbs.db
|
|||||||
|
|
||||||
# Env / secrets (local only)
|
# Env / secrets (local only)
|
||||||
.env
|
.env
|
||||||
|
.env.local
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
!.env.*.example
|
!.env.*.example
|
||||||
|
|||||||
@@ -71,4 +71,6 @@ pnpm --filter @evobgp/web run build
|
|||||||
|
|
||||||
Стек: React 19, TanStack Router/Query, shadcn/ui (base-nova, registry `@shadcn` + `@reui`), Tailwind v4, lucide-react. Legacy Svelte — в `web-legacy-svelte/` (архив, только референс при миграции).
|
Стек: React 19, TanStack Router/Query, shadcn/ui (base-nova, registry `@shadcn` + `@reui`), Tailwind v4, lucide-react. Legacy Svelte — в `web-legacy-svelte/` (архив, только референс при миграции).
|
||||||
|
|
||||||
UI-задачи начинаются с MCP `plugin-shadcn-shadcn` (search → examples → add command), затем CLI `pnpm dlx shadcn@latest add ...` из `apps/web`. См. также [.cursor/rules/context7-stack.mdc](.cursor/rules/context7-stack.mdc) для Context7 ID стека.
|
**UI design contract:** [`docs/ui-design-contract.md`](docs/ui-design-contract.md) — surface `frame`, kit `apps/web/src/components/reui-kit/`.
|
||||||
|
|
||||||
|
UI-задачи начинаются с MCP `user-reui` (`surface: "frame"`) + `plugin-shadcn-shadcn`, затем CLI `pnpm dlx shadcn@latest add ...` из `apps/web`. См. также [.cursor/rules/context7-stack.mdc](.cursor/rules/context7-stack.mdc) для Context7 ID стека.
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# EvoBGP web (Vite) — переменные окружения.
|
||||||
|
# Скопируйте в apps/web/.env.local (файл в .gitignore) и заполните.
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# ReUI Pro/Ultimate — ключ с https://reui.io/account (для `pnpm dlx shadcn add @reui/*`)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# REUI_LICENSE_KEY=
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# App Switcher — JSON с описанием шапки «Приложения» (fallback, когда portal
|
||||||
|
# недоступен либо VITE_AUTH_ENABLED=false).
|
||||||
|
# Схема: см. apps/web/src/lib/app-switcher-config.ts.
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# VITE_APP_SWITCHER={"menuLabel":"Приложения","apps":[...]}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Auth-portal SSO
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Включает JWT-гейт через auth-portal вместо локального evobgp_api_token.
|
||||||
|
# Пример:
|
||||||
|
# VITE_AUTH_ENABLED=true
|
||||||
|
# VITE_AUTH_PORTAL_URL=https://auth.shnt.top
|
||||||
|
#
|
||||||
|
# Backend опционально может отдавать GET /v1/auth/config
|
||||||
|
# ({ "required": true, "portal_url": "https://auth.shnt.top" }) —
|
||||||
|
# ответ имеет приоритет над VITE_* и позволяет менять режим без пересборки.
|
||||||
|
# VITE_AUTH_ENABLED=false
|
||||||
|
# VITE_AUTH_PORTAL_URL=http://localhost:5175
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Minus, TrendingDown, TrendingUp } from 'lucide-react'
|
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
|
||||||
export type AnalyticsKpiItem = {
|
export type AnalyticsKpiItem = {
|
||||||
@@ -12,39 +12,42 @@ export type AnalyticsKpiItem = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const TONE_CLASS = {
|
function deltaBadgeVariant(
|
||||||
success: 'text-success',
|
tone?: 'success' | 'warning' | 'destructive' | 'muted',
|
||||||
warning: 'text-warning',
|
): 'success-light' | 'warning-light' | 'destructive-light' | 'outline' {
|
||||||
destructive: 'text-destructive',
|
if (tone === 'success') return 'success-light'
|
||||||
muted: 'text-muted-foreground',
|
if (tone === 'warning') return 'warning-light'
|
||||||
} as const
|
if (tone === 'destructive') return 'destructive-light'
|
||||||
|
return 'outline'
|
||||||
function DeltaIcon({ direction }: { direction: AnalyticsKpiItem['delta'] extends infer D ? D extends { direction: infer Dir } ? Dir : never : never }) {
|
|
||||||
if (direction === 'up') return <TrendingUp className="size-3" />
|
|
||||||
if (direction === 'down') return <TrendingDown className="size-3" />
|
|
||||||
return <Minus className="size-3" />
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AnalyticsKpiRow({ items, className }: { items: AnalyticsKpiItem[]; className?: string }) {
|
function toKpiItem(item: AnalyticsKpiItem): KpiStatItem {
|
||||||
|
return {
|
||||||
|
id: item.label,
|
||||||
|
value: item.value,
|
||||||
|
label: item.label,
|
||||||
|
footer: item.delta ? (
|
||||||
|
<Badge variant={deltaBadgeVariant(item.delta.tone)} size="sm">
|
||||||
|
{item.delta.label}
|
||||||
|
</Badge>
|
||||||
|
) : undefined,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Compact KPI row inside analytics panels (stats-12 embedded tiles). */
|
||||||
|
export function AnalyticsKpiRow({
|
||||||
|
items,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
items: AnalyticsKpiItem[]
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('grid gap-4 sm:grid-cols-3', className)}>
|
<KpiStatGrid
|
||||||
{items.map((item) => (
|
items={items.map(toKpiItem)}
|
||||||
<div key={item.label} className="min-w-0 space-y-1">
|
embedded
|
||||||
<p className="text-xs text-muted-foreground">{item.label}</p>
|
className={cn(className)}
|
||||||
<p className="text-2xl font-semibold tracking-tight tabular-nums">{item.value}</p>
|
aria-label="Показатели"
|
||||||
{item.delta ? (
|
/>
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1 text-xs',
|
|
||||||
TONE_CLASS[item.delta.tone ?? 'muted'],
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<DeltaIcon direction={item.delta.direction} />
|
|
||||||
{item.delta.label}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuShortcut,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@evobgp/ui/components/dropdown-menu'
|
||||||
|
import {
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
useSidebar,
|
||||||
|
} from '@evobgp/ui/components/sidebar'
|
||||||
|
import { CheckIcon, ChevronsUpDownIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
APP_SWITCHER_ICONS,
|
||||||
|
CURRENT_APP_ID,
|
||||||
|
getCurrentApp,
|
||||||
|
} from '@/lib/app-switcher-config'
|
||||||
|
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
|
||||||
|
|
||||||
|
/** Sidebar app switcher — shared chrome etalon EvoBGP. @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]
|
||||||
|
|
||||||
|
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]
|
||||||
|
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}
|
||||||
|
nativeButton={false}
|
||||||
|
render={<a href={app.url} />}
|
||||||
|
>
|
||||||
|
<Icon />
|
||||||
|
{app.name}
|
||||||
|
{app.shortcut ? (
|
||||||
|
<DropdownMenuShortcut>{app.shortcut}</DropdownMenuShortcut>
|
||||||
|
) : null}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -40,14 +40,12 @@ export function BadgeTabs({
|
|||||||
value={value}
|
value={value}
|
||||||
defaultValue={defaultValue}
|
defaultValue={defaultValue}
|
||||||
onValueChange={onValueChange}
|
onValueChange={onValueChange}
|
||||||
|
orientation="horizontal"
|
||||||
className={cn('w-full', className)}
|
className={cn('w-full', className)}
|
||||||
>
|
>
|
||||||
<TabsList
|
<TabsList
|
||||||
variant="line"
|
variant="line"
|
||||||
className={cn(
|
className={cn('mb-3.5 w-full justify-start gap-6', listClassName)}
|
||||||
'mb-4 w-full justify-start gap-6',
|
|
||||||
listClassName,
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{items.map((item) => (
|
{items.map((item) => (
|
||||||
<TabsTrigger key={item.value} value={item.value} className="gap-2">
|
<TabsTrigger key={item.value} value={item.value} className="gap-2">
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState, type ComponentType } from "react"
|
||||||
|
|
||||||
|
import { useIsMobile } from "@/hooks/use-mobile"
|
||||||
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from "@evobgp/ui/components/tabs"
|
||||||
|
|
||||||
|
import { BillingTab } from "./billing-tab"
|
||||||
|
import { SETTINGS_TAB_ITEMS } from "./data"
|
||||||
|
import { NotificationsTab } from "./notifications-tab"
|
||||||
|
import { ProfileTab } from "./profile-tab"
|
||||||
|
import { SecurityTab } from "./security-tab"
|
||||||
|
|
||||||
|
const TAB_COMPONENTS: Record<string, ComponentType> = {
|
||||||
|
profile: ProfileTab,
|
||||||
|
security: SecurityTab,
|
||||||
|
notifications: NotificationsTab,
|
||||||
|
billing: BillingTab,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Settings Navigation ──
|
||||||
|
|
||||||
|
function SettingsNavigation({
|
||||||
|
isMobile,
|
||||||
|
activeValue,
|
||||||
|
}: {
|
||||||
|
isMobile: boolean
|
||||||
|
activeValue: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className={cn("min-w-0", isMobile ? "w-full" : "w-40 shrink-0")}>
|
||||||
|
{isMobile ? (
|
||||||
|
<div className="-mx-1 overflow-x-auto px-1 pb-1">
|
||||||
|
<TabsList className="h-auto w-max min-w-max justify-start gap-1 bg-transparent p-0">
|
||||||
|
{SETTINGS_TAB_ITEMS.map((tab) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={tab.value}
|
||||||
|
value={tab.value}
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-start gap-3 px-3 py-1.5 shadow-none",
|
||||||
|
activeValue === tab.value ? "bg-muted!" : "bg-transparent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
<span className="truncate">{tab.label}</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TabsList className="h-auto w-full flex-col items-stretch gap-1 bg-transparent p-0">
|
||||||
|
{SETTINGS_TAB_ITEMS.map((tab) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={tab.value}
|
||||||
|
value={tab.value}
|
||||||
|
className={cn(
|
||||||
|
"w-full justify-start gap-3 px-3 py-1.5 shadow-none",
|
||||||
|
activeValue === tab.value ? "bg-muted!" : "bg-transparent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
<span className="truncate">{tab.label}</span>
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AccountSettings() {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const [activeTab, setActiveTab] = useState("profile")
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-4xl space-y-8">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="px-1">
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight">
|
||||||
|
Account Settings
|
||||||
|
</h1>
|
||||||
|
<p className="text-muted-foreground max-w-2xl text-sm leading-relaxed">
|
||||||
|
Update your profile, access, notifications, and billing preferences.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<Tabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={setActiveTab}
|
||||||
|
orientation={isMobile ? "horizontal" : "vertical"}
|
||||||
|
className={cn("w-full gap-4 lg:gap-8")}
|
||||||
|
>
|
||||||
|
<SettingsNavigation isMobile={isMobile} activeValue={activeTab} />
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{SETTINGS_TAB_ITEMS.map((tab) => {
|
||||||
|
const TabComponent = TAB_COMPONENTS[tab.value]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TabsContent key={tab.value} value={tab.value} className="mt-0">
|
||||||
|
<TabComponent />
|
||||||
|
</TabsContent>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
|
||||||
|
import { Button } from "@evobgp/ui/components/button"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldDescription,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLabel,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSet,
|
||||||
|
} from "@evobgp/ui/components/field"
|
||||||
|
import { Input } from "@evobgp/ui/components/input"
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupInput,
|
||||||
|
InputGroupText,
|
||||||
|
} from "@evobgp/ui/components/input-group"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@evobgp/ui/components/select"
|
||||||
|
|
||||||
|
import { BILLING_PLANS, COUNTRIES } from "./data"
|
||||||
|
import { SettingRow } from "./setting-row"
|
||||||
|
import { SettingsCard } from "./settings-card"
|
||||||
|
import { SettingsFieldGroup } from "./settings-field-group"
|
||||||
|
import { createSelectValueHandler, getOptionLabel } from "./utils"
|
||||||
|
|
||||||
|
export function BillingTab() {
|
||||||
|
const [country, setCountry] = useState("us")
|
||||||
|
const handleCountryChange = createSelectValueHandler(setCountry)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Card */}
|
||||||
|
<SettingsCard title="Plan and billing" description="Current subscription">
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="Plan and billing"
|
||||||
|
description="Review your current subscription and plan options."
|
||||||
|
>
|
||||||
|
{BILLING_PLANS.map((plan, index) => (
|
||||||
|
<SettingRow
|
||||||
|
key={plan.id}
|
||||||
|
title={plan.name}
|
||||||
|
titleAddon={
|
||||||
|
plan.current ? (
|
||||||
|
<Badge variant="info-light" size="sm">
|
||||||
|
Current
|
||||||
|
</Badge>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
<span className="text-foreground font-medium">
|
||||||
|
{plan.price}
|
||||||
|
</span>{" "}
|
||||||
|
/ {plan.period} · {plan.features.join(", ")}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
last={index === BILLING_PLANS.length - 1}
|
||||||
|
>
|
||||||
|
<Button variant={plan.current ? "outline" : "ghost"} size="sm">
|
||||||
|
{plan.current ? "Manage" : "Switch"}
|
||||||
|
</Button>
|
||||||
|
</SettingRow>
|
||||||
|
))}
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsCard
|
||||||
|
title="Billing details"
|
||||||
|
description="Invoices and payments"
|
||||||
|
footer={<Button>Update billing</Button>}
|
||||||
|
>
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="Billing details"
|
||||||
|
description="Manage invoice contacts and payment details."
|
||||||
|
>
|
||||||
|
<SettingRow
|
||||||
|
title="Billing email"
|
||||||
|
description="Receives invoices and renewal notices."
|
||||||
|
labelFor="settings-7-billing-email"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="settings-7-billing-email"
|
||||||
|
defaultValue="[email protected]"
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Invoice profile"
|
||||||
|
description="Business details used on receipts and tax forms."
|
||||||
|
contentClassName="@md/field-group:w-[22rem]"
|
||||||
|
>
|
||||||
|
<FieldSet className="w-full gap-3">
|
||||||
|
<FieldLegend className="sr-only">Invoice profile</FieldLegend>
|
||||||
|
<FieldDescription className="sr-only">
|
||||||
|
Company identity used for invoices and tax documents.
|
||||||
|
</FieldDescription>
|
||||||
|
|
||||||
|
<FieldGroup className="gap-4">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="settings-7-company-name">
|
||||||
|
Company name
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="settings-7-company-name"
|
||||||
|
defaultValue="Acme Labs"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="settings-7-billing-country">
|
||||||
|
Country
|
||||||
|
</FieldLabel>
|
||||||
|
<Select value={country} onValueChange={handleCountryChange}>
|
||||||
|
<SelectTrigger
|
||||||
|
id="settings-7-billing-country"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<SelectValue>
|
||||||
|
{getOptionLabel(COUNTRIES, country)}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{COUNTRIES.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</FieldSet>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Tax ID"
|
||||||
|
description="Optional number used for VAT or company invoices."
|
||||||
|
labelFor="settings-7-tax-id"
|
||||||
|
>
|
||||||
|
<InputGroup className="w-full">
|
||||||
|
<InputGroupAddon align="inline-start">
|
||||||
|
<InputGroupText>VAT</InputGroupText>
|
||||||
|
</InputGroupAddon>
|
||||||
|
<InputGroupInput
|
||||||
|
id="settings-7-tax-id"
|
||||||
|
defaultValue="US-2048-ACME"
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Card on file"
|
||||||
|
description="Used for monthly renewals."
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
|
<span className="text-muted-foreground text-sm">
|
||||||
|
Visa ending in 4242
|
||||||
|
</span>
|
||||||
|
<Button variant="outline">Update</Button>
|
||||||
|
</div>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
</SettingsCard>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { type ReactNode } from "react"
|
||||||
|
import { UserIcon, ShieldIcon, BellIcon, CreditCardIcon, MonitorIcon, SmartphoneIcon } from "lucide-react"
|
||||||
|
|
||||||
|
// ── Types ──
|
||||||
|
|
||||||
|
export type SelectOption = {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BillingPlan = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
price: string
|
||||||
|
period: string
|
||||||
|
current: boolean
|
||||||
|
features: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Session = {
|
||||||
|
id: string
|
||||||
|
device: string
|
||||||
|
browser: string
|
||||||
|
location: string
|
||||||
|
lastActive: string
|
||||||
|
current: boolean
|
||||||
|
icon: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SettingsTabItem = {
|
||||||
|
value: string
|
||||||
|
label: string
|
||||||
|
icon: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Data ──
|
||||||
|
|
||||||
|
export const SETTINGS_TAB_ITEMS: SettingsTabItem[] = [
|
||||||
|
{
|
||||||
|
value: "profile",
|
||||||
|
label: "My Profile",
|
||||||
|
icon: (
|
||||||
|
<UserIcon aria-hidden="true" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "security",
|
||||||
|
label: "Security",
|
||||||
|
icon: (
|
||||||
|
<ShieldIcon aria-hidden="true" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "notifications",
|
||||||
|
label: "Notifications",
|
||||||
|
icon: (
|
||||||
|
<BellIcon aria-hidden="true" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: "billing",
|
||||||
|
label: "Billing",
|
||||||
|
icon: (
|
||||||
|
<CreditCardIcon aria-hidden="true" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const TIMEZONES: SelectOption[] = [
|
||||||
|
{ value: "utc-8", label: "UTC-8 (Pacific Time)" },
|
||||||
|
{ value: "utc-5", label: "UTC-5 (Eastern Time)" },
|
||||||
|
{ value: "utc+0", label: "UTC+0 (London)" },
|
||||||
|
{ value: "utc+1", label: "UTC+1 (Berlin)" },
|
||||||
|
{ value: "utc+9", label: "UTC+9 (Tokyo)" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ROLES: SelectOption[] = [
|
||||||
|
{ value: "engineering-lead", label: "Engineering Lead" },
|
||||||
|
{ value: "developer", label: "Developer" },
|
||||||
|
{ value: "designer", label: "Designer" },
|
||||||
|
{ value: "product-manager", label: "Product Manager" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const COUNTRIES: SelectOption[] = [
|
||||||
|
{ value: "us", label: "United States" },
|
||||||
|
{ value: "uk", label: "United Kingdom" },
|
||||||
|
{ value: "de", label: "Germany" },
|
||||||
|
{ value: "uz", label: "Uzbekistan" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const TIMEOUTS: SelectOption[] = [
|
||||||
|
{ value: "5", label: "5 minutes" },
|
||||||
|
{ value: "10", label: "10 minutes" },
|
||||||
|
{ value: "15", label: "15 minutes" },
|
||||||
|
{ value: "30", label: "30 minutes" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const QUIET_HOURS: SelectOption[] = [
|
||||||
|
{ value: "18:00", label: "6:00 PM" },
|
||||||
|
{ value: "20:00", label: "8:00 PM" },
|
||||||
|
{ value: "22:00", label: "10:00 PM" },
|
||||||
|
{ value: "23:00", label: "11:00 PM" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const QUIET_HOURS_END: SelectOption[] = [
|
||||||
|
{ value: "06:00", label: "6:00 AM" },
|
||||||
|
{ value: "08:00", label: "8:00 AM" },
|
||||||
|
{ value: "09:00", label: "9:00 AM" },
|
||||||
|
{ value: "10:00", label: "10:00 AM" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const DIGEST_CADENCE: SelectOption[] = [
|
||||||
|
{ value: "daily", label: "Daily summary" },
|
||||||
|
{ value: "weekly", label: "Weekly digest" },
|
||||||
|
{ value: "mentions", label: "Only mentions" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const RECOVERY_METHODS: SelectOption[] = [
|
||||||
|
{ value: "authenticator", label: "Authenticator first" },
|
||||||
|
{ value: "sms", label: "SMS fallback" },
|
||||||
|
{ value: "email", label: "Email fallback" },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const BILLING_PLANS: BillingPlan[] = [
|
||||||
|
{
|
||||||
|
id: "free",
|
||||||
|
name: "Free",
|
||||||
|
price: "$0",
|
||||||
|
period: "forever",
|
||||||
|
current: false,
|
||||||
|
features: ["1 workspace", "Basic exports", "Community support"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "pro",
|
||||||
|
name: "Pro",
|
||||||
|
price: "$29",
|
||||||
|
period: "per month",
|
||||||
|
current: true,
|
||||||
|
features: ["Unlimited workspaces", "Automations", "Priority support"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "team",
|
||||||
|
name: "Team",
|
||||||
|
price: "$79",
|
||||||
|
period: "per month",
|
||||||
|
current: false,
|
||||||
|
features: ["Everything in Pro", "SSO", "Shared billing"],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const SESSIONS: Session[] = [
|
||||||
|
{
|
||||||
|
id: "sess-1",
|
||||||
|
device: "macOS",
|
||||||
|
browser: "Chrome",
|
||||||
|
location: "San Francisco, CA",
|
||||||
|
lastActive: "Active now",
|
||||||
|
current: true,
|
||||||
|
icon: (
|
||||||
|
<MonitorIcon aria-hidden="true" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sess-2",
|
||||||
|
device: "iPhone",
|
||||||
|
browser: "Safari",
|
||||||
|
location: "San Francisco, CA",
|
||||||
|
lastActive: "2 hours ago",
|
||||||
|
current: false,
|
||||||
|
icon: (
|
||||||
|
<SmartphoneIcon aria-hidden="true" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sess-3",
|
||||||
|
device: "Windows",
|
||||||
|
browser: "Firefox",
|
||||||
|
location: "New York, NY",
|
||||||
|
lastActive: "3 days ago",
|
||||||
|
current: false,
|
||||||
|
icon: (
|
||||||
|
<MonitorIcon aria-hidden="true" />
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
|
||||||
|
import { Button } from "@evobgp/ui/components/button"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldDescription,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLabel,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSet,
|
||||||
|
} from "@evobgp/ui/components/field"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@evobgp/ui/components/select"
|
||||||
|
import { Switch } from "@evobgp/ui/components/switch"
|
||||||
|
|
||||||
|
import { DIGEST_CADENCE, QUIET_HOURS, QUIET_HOURS_END, TIMEOUTS } from "./data"
|
||||||
|
import { SettingRow } from "./setting-row"
|
||||||
|
import { SettingsCard } from "./settings-card"
|
||||||
|
import { SettingsFieldGroup } from "./settings-field-group"
|
||||||
|
import { createSelectValueHandler, getOptionLabel } from "./utils"
|
||||||
|
|
||||||
|
export function NotificationsTab() {
|
||||||
|
const [autoDismiss, setAutoDismiss] = useState("10")
|
||||||
|
const [quietStart, setQuietStart] = useState("20:00")
|
||||||
|
const [quietEnd, setQuietEnd] = useState("08:00")
|
||||||
|
const [digestCadence, setDigestCadence] = useState("weekly")
|
||||||
|
const handleAutoDismissChange = createSelectValueHandler(setAutoDismiss)
|
||||||
|
const handleQuietStartChange = createSelectValueHandler(setQuietStart)
|
||||||
|
const handleQuietEndChange = createSelectValueHandler(setQuietEnd)
|
||||||
|
const handleDigestCadenceChange = createSelectValueHandler(setDigestCadence)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Card */}
|
||||||
|
<SettingsCard title="Activity alerts" description="Desktop and badge">
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="Activity alerts"
|
||||||
|
description="Control desktop, badge, and sound alerts."
|
||||||
|
>
|
||||||
|
<SettingRow
|
||||||
|
title="Desktop notifications"
|
||||||
|
description="Show alerts for mentions and approvals."
|
||||||
|
labelFor="settings-7-desktop-notifications"
|
||||||
|
>
|
||||||
|
<Switch id="settings-7-desktop-notifications" defaultChecked />
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Quiet hours"
|
||||||
|
description="Hold non-urgent alerts outside your workday."
|
||||||
|
contentClassName="@md/field-group:w-[22rem]"
|
||||||
|
>
|
||||||
|
<FieldSet className="w-full gap-3">
|
||||||
|
<FieldLegend className="sr-only">Quiet hours</FieldLegend>
|
||||||
|
<FieldDescription className="sr-only">
|
||||||
|
Define when non-urgent activity should stay muted.
|
||||||
|
</FieldDescription>
|
||||||
|
|
||||||
|
<FieldGroup className="gap-3 sm:grid sm:grid-cols-2">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="settings-7-quiet-start">
|
||||||
|
Start
|
||||||
|
</FieldLabel>
|
||||||
|
<Select
|
||||||
|
value={quietStart}
|
||||||
|
onValueChange={handleQuietStartChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger
|
||||||
|
id="settings-7-quiet-start"
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
<SelectValue>
|
||||||
|
{getOptionLabel(QUIET_HOURS, quietStart)}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{QUIET_HOURS.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="settings-7-quiet-end">End</FieldLabel>
|
||||||
|
<Select value={quietEnd} onValueChange={handleQuietEndChange}>
|
||||||
|
<SelectTrigger id="settings-7-quiet-end" className="w-full">
|
||||||
|
<SelectValue>
|
||||||
|
{getOptionLabel(QUIET_HOURS_END, quietEnd)}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{QUIET_HOURS_END.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</FieldSet>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Unread badge"
|
||||||
|
description="Display a badge when new activity arrives."
|
||||||
|
labelFor="settings-7-unread-badge"
|
||||||
|
>
|
||||||
|
<Switch id="settings-7-unread-badge" defaultChecked />
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Auto dismiss"
|
||||||
|
description="Choose how long alerts stay visible."
|
||||||
|
labelFor="settings-7-auto-dismiss"
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<Select value={autoDismiss} onValueChange={handleAutoDismissChange}>
|
||||||
|
<SelectTrigger id="settings-7-auto-dismiss" className="w-full">
|
||||||
|
<SelectValue>
|
||||||
|
{getOptionLabel(TIMEOUTS, autoDismiss)}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{TIMEOUTS.map((timeout) => (
|
||||||
|
<SelectItem key={timeout.value} value={timeout.value}>
|
||||||
|
{timeout.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsCard
|
||||||
|
title="Email updates"
|
||||||
|
description="Inbox preferences"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline">Reset</Button>
|
||||||
|
<Button>Save preferences</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="Email updates"
|
||||||
|
description="Control which emails reach your inbox."
|
||||||
|
>
|
||||||
|
<SettingRow
|
||||||
|
title="Team communication"
|
||||||
|
description="Messages, approvals, and activity summaries."
|
||||||
|
labelFor="settings-7-team-communication"
|
||||||
|
>
|
||||||
|
<Switch id="settings-7-team-communication" defaultChecked />
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Product announcements"
|
||||||
|
description="Releases, improvements, and launches."
|
||||||
|
labelFor="settings-7-product-announcements"
|
||||||
|
>
|
||||||
|
<Switch id="settings-7-product-announcements" />
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Weekly digest"
|
||||||
|
description="A recap of workspaces, mentions, and tasks."
|
||||||
|
labelFor="settings-7-weekly-digest"
|
||||||
|
>
|
||||||
|
<Switch id="settings-7-weekly-digest" defaultChecked />
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Digest cadence"
|
||||||
|
description="Set how often summary emails arrive."
|
||||||
|
labelFor="settings-7-digest-cadence"
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={digestCadence}
|
||||||
|
onValueChange={handleDigestCadenceChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="settings-7-digest-cadence" className="w-full">
|
||||||
|
<SelectValue>
|
||||||
|
{getOptionLabel(DIGEST_CADENCE, digestCadence)}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{DIGEST_CADENCE.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
</SettingsCard>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { useState } from "react"
|
||||||
|
import { useFileUpload } from "@/hooks/use-file-upload"
|
||||||
|
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
} from "@evobgp/ui/components/avatar"
|
||||||
|
import { Button } from "@evobgp/ui/components/button"
|
||||||
|
import { UserCircle, XIcon, UploadIcon } from "lucide-react"
|
||||||
|
|
||||||
|
interface ProfileAvatarUploadProps {
|
||||||
|
defaultAvatar: string
|
||||||
|
alt: string
|
||||||
|
inputId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileAvatarUpload({
|
||||||
|
defaultAvatar,
|
||||||
|
alt,
|
||||||
|
inputId,
|
||||||
|
}: ProfileAvatarUploadProps) {
|
||||||
|
const [removedCurrentPhoto, setRemovedCurrentPhoto] = useState(false)
|
||||||
|
const [{ files }, { removeFile, openFileDialog, getInputProps }] =
|
||||||
|
useFileUpload({
|
||||||
|
accept: "image/*",
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentFile = files[0] ?? null
|
||||||
|
const hasSavedPhoto = Boolean(defaultAvatar) && !removedCurrentPhoto
|
||||||
|
const previewUrl =
|
||||||
|
currentFile?.preview ?? (hasSavedPhoto ? defaultAvatar : null)
|
||||||
|
const hasPhoto = Boolean(previewUrl)
|
||||||
|
|
||||||
|
const handleCancelUpload = () => {
|
||||||
|
if (!currentFile) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
removeFile(currentFile.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRemovePhoto = () => {
|
||||||
|
if (currentFile) {
|
||||||
|
removeFile(currentFile.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
setRemovedCurrentPhoto(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex grow flex-wrap items-center justify-start gap-2">
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="relative">
|
||||||
|
<Avatar className="size-10">
|
||||||
|
<AvatarImage src={previewUrl ?? undefined} alt={alt} />
|
||||||
|
<AvatarFallback className="text-muted-foreground bg-muted">
|
||||||
|
<UserCircle aria-hidden="true" className="size-4 opacity-60" />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
|
||||||
|
{currentFile ? (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon-xs"
|
||||||
|
onClick={handleCancelUpload}
|
||||||
|
className="absolute -top-1 -right-1 size-4 rounded-full"
|
||||||
|
aria-label={`Cancel ${currentFile.file.name}`}
|
||||||
|
>
|
||||||
|
<XIcon aria-hidden="true" />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative inline-flex">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={openFileDialog}
|
||||||
|
aria-haspopup="dialog"
|
||||||
|
>
|
||||||
|
<UploadIcon aria-hidden="true" />
|
||||||
|
{hasPhoto ? "Change" : "Upload"}
|
||||||
|
</Button>
|
||||||
|
<input
|
||||||
|
{...getInputProps({ id: inputId })}
|
||||||
|
className="sr-only"
|
||||||
|
aria-label="Upload profile image"
|
||||||
|
tabIndex={-1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasPhoto ? (
|
||||||
|
<Button variant="outline" size="sm" onClick={handleRemovePhoto}>
|
||||||
|
<XIcon aria-hidden="true" />
|
||||||
|
Remove
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import { useState } from "react"
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertAction,
|
||||||
|
AlertDescription,
|
||||||
|
AlertTitle,
|
||||||
|
} from "@/components/reui/alert"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
|
||||||
|
import { Button } from "@evobgp/ui/components/button"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldDescription,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLabel,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSet,
|
||||||
|
} from "@evobgp/ui/components/field"
|
||||||
|
import { Input } from "@evobgp/ui/components/input"
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupInput,
|
||||||
|
InputGroupText,
|
||||||
|
} from "@evobgp/ui/components/input-group"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@evobgp/ui/components/select"
|
||||||
|
import { Textarea } from "@evobgp/ui/components/textarea"
|
||||||
|
import { ROLES, TIMEZONES } from "./data"
|
||||||
|
import { ProfileAvatarUpload } from "./profile-avatar-upload"
|
||||||
|
import { SettingRow } from "./setting-row"
|
||||||
|
import { SettingsCard } from "./settings-card"
|
||||||
|
import { SettingsFieldGroup } from "./settings-field-group"
|
||||||
|
import { createSelectValueHandler, getOptionLabel } from "./utils"
|
||||||
|
import { UserIcon } from "lucide-react"
|
||||||
|
|
||||||
|
export function ProfileTab() {
|
||||||
|
const [role, setRole] = useState("engineering-lead")
|
||||||
|
const [timezone, setTimezone] = useState("utc-8")
|
||||||
|
const handleRoleChange = createSelectValueHandler(setRole)
|
||||||
|
const handleTimezoneChange = createSelectValueHandler(setTimezone)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Alert variant="warning">
|
||||||
|
<UserIcon aria-hidden="true" />
|
||||||
|
<AlertTitle>Complete your profile.</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
Add a photo and keep your role and timezone current.
|
||||||
|
</AlertDescription>
|
||||||
|
<AlertAction>
|
||||||
|
<Button type="button" variant="outline" size="xs">
|
||||||
|
Dismiss
|
||||||
|
</Button>
|
||||||
|
<Button type="button" size="xs">
|
||||||
|
Update
|
||||||
|
</Button>
|
||||||
|
</AlertAction>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<SettingsCard
|
||||||
|
title="My profile"
|
||||||
|
description="Public account details"
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="outline">Cancel</Button>
|
||||||
|
<Button>Save changes</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="Profile fields"
|
||||||
|
description="Update your personal profile details."
|
||||||
|
>
|
||||||
|
<SettingRow
|
||||||
|
title="Photo"
|
||||||
|
description="Shown in comments and mentions."
|
||||||
|
>
|
||||||
|
<ProfileAvatarUpload
|
||||||
|
defaultAvatar="https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?w=96&h=96&dpr=2&q=80"
|
||||||
|
alt="Alex Morgan"
|
||||||
|
inputId="settings-7-profile-photo"
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Full name"
|
||||||
|
description="Used across the workspace."
|
||||||
|
labelFor="settings-7-full-name"
|
||||||
|
>
|
||||||
|
<Input id="settings-7-full-name" defaultValue="Alex Morgan" />
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Email address"
|
||||||
|
description="Primary sign-in email."
|
||||||
|
labelFor="settings-7-email"
|
||||||
|
titleAddon={<Badge variant="success-light">Verified</Badge>}
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="settings-7-email"
|
||||||
|
defaultValue="[email protected]"
|
||||||
|
type="email"
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Username"
|
||||||
|
description="Visible in mentions and links."
|
||||||
|
labelFor="settings-7-username"
|
||||||
|
>
|
||||||
|
<InputGroup className="w-full">
|
||||||
|
<InputGroupAddon align="inline-start">
|
||||||
|
<InputGroupText>@</InputGroupText>
|
||||||
|
</InputGroupAddon>
|
||||||
|
<InputGroupInput
|
||||||
|
id="settings-7-username"
|
||||||
|
defaultValue="alexmorgan"
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Profile details"
|
||||||
|
description="Public details shared across the workspace."
|
||||||
|
contentClassName="@md/field-group:w-[22rem]"
|
||||||
|
>
|
||||||
|
<FieldSet className="w-full gap-3">
|
||||||
|
<FieldLegend className="sr-only">Profile details</FieldLegend>
|
||||||
|
<FieldDescription className="sr-only">
|
||||||
|
Public profile and workspace defaults.
|
||||||
|
</FieldDescription>
|
||||||
|
|
||||||
|
<FieldGroup className="gap-4">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="settings-7-role">Role</FieldLabel>
|
||||||
|
<Select value={role} onValueChange={handleRoleChange}>
|
||||||
|
<SelectTrigger id="settings-7-role" className="w-full">
|
||||||
|
<SelectValue>{getOptionLabel(ROLES, role)}</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{ROLES.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="settings-7-timezone">
|
||||||
|
Time zone
|
||||||
|
</FieldLabel>
|
||||||
|
<Select value={timezone} onValueChange={handleTimezoneChange}>
|
||||||
|
<SelectTrigger id="settings-7-timezone" className="w-full">
|
||||||
|
<SelectValue>
|
||||||
|
{getOptionLabel(TIMEZONES, timezone)}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{TIMEZONES.map((item) => (
|
||||||
|
<SelectItem key={item.value} value={item.value}>
|
||||||
|
{item.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="settings-7-website">Website</FieldLabel>
|
||||||
|
<InputGroup className="w-full">
|
||||||
|
<InputGroupAddon align="inline-start">
|
||||||
|
<InputGroupText>https://</InputGroupText>
|
||||||
|
</InputGroupAddon>
|
||||||
|
<InputGroupInput
|
||||||
|
id="settings-7-website"
|
||||||
|
defaultValue="alexmorgan.dev"
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</FieldSet>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Bio"
|
||||||
|
description="Short profile summary."
|
||||||
|
labelFor="settings-7-bio"
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<Textarea
|
||||||
|
id="settings-7-bio"
|
||||||
|
defaultValue="Building developer tools at Acme. Previously at Vercel and Stripe."
|
||||||
|
rows={4}
|
||||||
|
className="min-h-24 resize-none"
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
</SettingsCard>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,257 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { Fragment, useState } from "react"
|
||||||
|
import { Badge } from "@/components/reui/badge"
|
||||||
|
|
||||||
|
import { Button } from "@evobgp/ui/components/button"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldDescription,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLabel,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSet,
|
||||||
|
} from "@evobgp/ui/components/field"
|
||||||
|
import { Input } from "@evobgp/ui/components/input"
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupInput,
|
||||||
|
InputGroupText,
|
||||||
|
} from "@evobgp/ui/components/input-group"
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemActions,
|
||||||
|
ItemContent,
|
||||||
|
ItemDescription,
|
||||||
|
ItemGroup,
|
||||||
|
ItemMedia,
|
||||||
|
ItemSeparator,
|
||||||
|
ItemTitle,
|
||||||
|
} from "@evobgp/ui/components/item"
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@evobgp/ui/components/select"
|
||||||
|
import { Switch } from "@evobgp/ui/components/switch"
|
||||||
|
import { RECOVERY_METHODS, SESSIONS } from "./data"
|
||||||
|
import { SettingRow } from "./setting-row"
|
||||||
|
import { SettingsCard } from "./settings-card"
|
||||||
|
import { SettingsFieldGroup } from "./settings-field-group"
|
||||||
|
import { createSelectValueHandler, getOptionLabel } from "./utils"
|
||||||
|
import { LogOutIcon } from "lucide-react"
|
||||||
|
|
||||||
|
export function SecurityTab() {
|
||||||
|
const [recoveryMethod, setRecoveryMethod] = useState("authenticator")
|
||||||
|
const handleRecoveryMethodChange = createSelectValueHandler(setRecoveryMethod)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Card */}
|
||||||
|
<SettingsCard
|
||||||
|
title="Change password"
|
||||||
|
description="Access protection"
|
||||||
|
footer={<Button>Update password</Button>}
|
||||||
|
>
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="Password settings"
|
||||||
|
description="Update your password and keep your account protected."
|
||||||
|
>
|
||||||
|
<SettingRow
|
||||||
|
title="Current password"
|
||||||
|
description="Verify your identity before making changes."
|
||||||
|
labelFor="settings-7-current-password"
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="settings-7-current-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Enter current password"
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="New password"
|
||||||
|
description="Use at least 12 characters and a unique phrase."
|
||||||
|
contentClassName="@md/field-group:w-[22rem]"
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<FieldSet className="w-full gap-3">
|
||||||
|
<FieldLegend className="sr-only">New password</FieldLegend>
|
||||||
|
<FieldDescription className="sr-only">
|
||||||
|
Create and confirm the next password for this account.
|
||||||
|
</FieldDescription>
|
||||||
|
|
||||||
|
<FieldGroup className="gap-4">
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="settings-7-new-password">
|
||||||
|
New password
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="settings-7-new-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Create a new password"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor="settings-7-confirm-password">
|
||||||
|
Confirm password
|
||||||
|
</FieldLabel>
|
||||||
|
<Input
|
||||||
|
id="settings-7-confirm-password"
|
||||||
|
type="password"
|
||||||
|
placeholder="Confirm the new password"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
</FieldGroup>
|
||||||
|
</FieldSet>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsCard
|
||||||
|
title="Two-step verification"
|
||||||
|
description="Extra sign-in checks"
|
||||||
|
>
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="Two-step verification"
|
||||||
|
description="Add backup checks for future sign-ins."
|
||||||
|
>
|
||||||
|
<SettingRow
|
||||||
|
title="Authenticator app"
|
||||||
|
description="Use one-time codes from an app."
|
||||||
|
labelFor="settings-7-authenticator-app"
|
||||||
|
titleAddon={
|
||||||
|
<Badge variant="info-light" size="sm">
|
||||||
|
Recommended
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Switch id="settings-7-authenticator-app" defaultChecked />
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Recovery phone"
|
||||||
|
description="Used if you lose access to your authenticator app."
|
||||||
|
labelFor="settings-7-recovery-phone"
|
||||||
|
>
|
||||||
|
<InputGroup className="w-full">
|
||||||
|
<InputGroupAddon align="inline-start">
|
||||||
|
<InputGroupText>+1</InputGroupText>
|
||||||
|
</InputGroupAddon>
|
||||||
|
<InputGroupInput
|
||||||
|
id="settings-7-recovery-phone"
|
||||||
|
defaultValue="415 555 0148"
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Delivery method"
|
||||||
|
description="Choose how backup verification requests are delivered."
|
||||||
|
labelFor="settings-7-recovery-method"
|
||||||
|
>
|
||||||
|
<Select
|
||||||
|
value={recoveryMethod}
|
||||||
|
onValueChange={handleRecoveryMethodChange}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="settings-7-recovery-method" className="w-full">
|
||||||
|
<SelectValue>
|
||||||
|
{getOptionLabel(RECOVERY_METHODS, recoveryMethod)}
|
||||||
|
</SelectValue>
|
||||||
|
</SelectTrigger>
|
||||||
|
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{RECOVERY_METHODS.map((method) => (
|
||||||
|
<SelectItem key={method.value} value={method.value}>
|
||||||
|
{method.label}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</SettingRow>
|
||||||
|
|
||||||
|
<SettingRow
|
||||||
|
title="Trusted devices"
|
||||||
|
description="Skip repeat prompts on known devices."
|
||||||
|
labelFor="settings-7-trusted-devices"
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<Switch id="settings-7-trusted-devices" defaultChecked />
|
||||||
|
</SettingRow>
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsCard title="Active sessions" description="Signed-in devices">
|
||||||
|
<ItemGroup className="gap-0">
|
||||||
|
{SESSIONS.map((session, index) => (
|
||||||
|
<Fragment key={session.id}>
|
||||||
|
{index > 0 ? <ItemSeparator className="my-0" /> : null}
|
||||||
|
<Item className="min-h-0 items-center gap-4 px-5 py-3.5">
|
||||||
|
<ItemMedia className="self-center!">
|
||||||
|
<Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60">
|
||||||
|
{session.icon}
|
||||||
|
</Item>
|
||||||
|
</ItemMedia>
|
||||||
|
|
||||||
|
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
|
||||||
|
<ItemTitle className="gap-2 leading-5">
|
||||||
|
{session.browser} on {session.device}
|
||||||
|
{session.current ? (
|
||||||
|
<Badge variant="success-light" size="xs">
|
||||||
|
Current
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</ItemTitle>
|
||||||
|
<ItemDescription className="leading-5">
|
||||||
|
{session.location} · {session.lastActive}
|
||||||
|
</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
|
||||||
|
<ItemActions className="w-28 shrink-0 justify-end self-center">
|
||||||
|
{session.current ? (
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
This device
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button variant="ghost" size="sm">
|
||||||
|
<LogOutIcon data-icon="inline-start" aria-hidden="true" />
|
||||||
|
Revoke
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ItemActions>
|
||||||
|
</Item>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</ItemGroup>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsCard title="Delete account" description="Irreversible changes">
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="Delete account"
|
||||||
|
description="Review irreversible actions before deleting your account."
|
||||||
|
>
|
||||||
|
<SettingRow
|
||||||
|
title="Close account"
|
||||||
|
description="Permanently remove your account, sessions, and recovery settings."
|
||||||
|
last
|
||||||
|
contentClassName="@md/field-group:w-[22rem]"
|
||||||
|
>
|
||||||
|
<div className="flex w-full flex-col items-start gap-2 @md/field-group:items-end">
|
||||||
|
<p className="text-muted-foreground text-xs leading-5">
|
||||||
|
This action cannot be undone.
|
||||||
|
</p>
|
||||||
|
<Button variant="destructive">Delete account</Button>
|
||||||
|
</div>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
</SettingsCard>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { type ReactNode } from "react"
|
||||||
|
|
||||||
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
import {
|
||||||
|
Field,
|
||||||
|
FieldContent,
|
||||||
|
FieldDescription,
|
||||||
|
FieldLabel,
|
||||||
|
FieldSeparator,
|
||||||
|
FieldTitle,
|
||||||
|
} from "@evobgp/ui/components/field"
|
||||||
|
|
||||||
|
interface SettingRowProps {
|
||||||
|
title: string
|
||||||
|
description?: ReactNode
|
||||||
|
children: ReactNode
|
||||||
|
last?: boolean
|
||||||
|
labelFor?: string
|
||||||
|
contentClassName?: string
|
||||||
|
titleAddon?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingRow({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
last,
|
||||||
|
labelFor,
|
||||||
|
contentClassName,
|
||||||
|
titleAddon,
|
||||||
|
}: SettingRowProps) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Field orientation="responsive" className="gap-4 px-5 py-3">
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5 @md/field-group:max-w-sm">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{labelFor ? (
|
||||||
|
<FieldLabel htmlFor={labelFor}>{title}</FieldLabel>
|
||||||
|
) : (
|
||||||
|
<FieldTitle>{title}</FieldTitle>
|
||||||
|
)}
|
||||||
|
{titleAddon}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{description ? (
|
||||||
|
<FieldDescription>{description}</FieldDescription>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<FieldContent
|
||||||
|
className={cn("min-w-0 @md/field-group:w-78", contentClassName)}
|
||||||
|
>
|
||||||
|
<div className="flex w-full justify-start @md/field-group:justify-end">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</FieldContent>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
{!last ? <FieldSeparator /> : null}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { type ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameFooter,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
|
||||||
|
interface SettingsCardProps {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
children: ReactNode
|
||||||
|
footer?: ReactNode
|
||||||
|
className?: string
|
||||||
|
contentClassName?: string
|
||||||
|
footerClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Settings section — Frame surface. Preview: https://reui.io/preview/base/settings-16 */
|
||||||
|
export function SettingsCard({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
className,
|
||||||
|
contentClassName,
|
||||||
|
footerClassName,
|
||||||
|
}: SettingsCardProps) {
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm" className={cn('w-full gap-0 p-0', className)}>
|
||||||
|
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||||
|
<FrameHeader className="gap-0 border-b px-5 py-3">
|
||||||
|
<FrameTitle>{title}</FrameTitle>
|
||||||
|
{description ? <FrameDescription>{description}</FrameDescription> : null}
|
||||||
|
</FrameHeader>
|
||||||
|
|
||||||
|
<div className={cn('min-w-0', contentClassName)}>{children}</div>
|
||||||
|
|
||||||
|
{footer ? (
|
||||||
|
<FrameFooter
|
||||||
|
className={cn('justify-end gap-2 border-t px-5 py-3', footerClassName)}
|
||||||
|
>
|
||||||
|
{footer}
|
||||||
|
</FrameFooter>
|
||||||
|
) : null}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { type ReactNode } from "react"
|
||||||
|
|
||||||
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
import {
|
||||||
|
FieldDescription,
|
||||||
|
FieldGroup,
|
||||||
|
FieldLegend,
|
||||||
|
FieldSet,
|
||||||
|
} from "@evobgp/ui/components/field"
|
||||||
|
|
||||||
|
interface SettingsFieldGroupProps {
|
||||||
|
legend: string
|
||||||
|
description: string
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
fieldGroupClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsFieldGroup({
|
||||||
|
legend,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
fieldGroupClassName,
|
||||||
|
}: SettingsFieldGroupProps) {
|
||||||
|
return (
|
||||||
|
<FieldSet className={cn("gap-0", className)}>
|
||||||
|
<FieldLegend className="sr-only">{legend}</FieldLegend>
|
||||||
|
{/* Description */}
|
||||||
|
<FieldDescription className="sr-only">{description}</FieldDescription>
|
||||||
|
{/* List */}
|
||||||
|
<FieldGroup className={cn("gap-0", fieldGroupClassName)}>
|
||||||
|
{children}
|
||||||
|
</FieldGroup>
|
||||||
|
</FieldSet>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
"use client"
|
||||||
|
|
||||||
|
import { type Dispatch, type SetStateAction } from "react"
|
||||||
|
|
||||||
|
import type { SelectOption } from "./data"
|
||||||
|
|
||||||
|
export function getOptionLabel<T extends SelectOption>(
|
||||||
|
options: T[],
|
||||||
|
value: string
|
||||||
|
) {
|
||||||
|
return options.find((option) => option.value === value)?.label ?? value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSelectValueHandler(
|
||||||
|
setValue: Dispatch<SetStateAction<string>>
|
||||||
|
) {
|
||||||
|
return (value: string | null) => {
|
||||||
|
if (value !== null) {
|
||||||
|
setValue(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { AccountSettings } from "./components/account-settings"
|
||||||
|
|
||||||
|
export function Page() {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh w-full items-start justify-center p-4 sm:p-6 md:p-10">
|
||||||
|
<AccountSettings />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
|
||||||
|
export interface CountedLineTab {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CountedLineTabsProps {
|
||||||
|
tabs: CountedLineTab[]
|
||||||
|
value: string
|
||||||
|
onValueChange: (value: string) => void
|
||||||
|
className?: string
|
||||||
|
listClassName?: string
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Line tabs with count pills (c-tabs-2 / data-grid-filtering-2). */
|
||||||
|
export function CountedLineTabs({
|
||||||
|
tabs,
|
||||||
|
value,
|
||||||
|
onValueChange,
|
||||||
|
className,
|
||||||
|
listClassName,
|
||||||
|
children,
|
||||||
|
}: CountedLineTabsProps) {
|
||||||
|
return (
|
||||||
|
<Tabs value={value} onValueChange={onValueChange} className={className}>
|
||||||
|
<TabsList variant="line" className={cn('gap-5', listClassName)}>
|
||||||
|
{tabs.map((tab) => (
|
||||||
|
<TabsTrigger
|
||||||
|
key={tab.id}
|
||||||
|
value={tab.id}
|
||||||
|
className="text-muted-foreground hover:text-foreground h-auto gap-2 px-0 pb-3 after:bottom-0"
|
||||||
|
>
|
||||||
|
<span>{tab.label}</span>
|
||||||
|
{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">
|
||||||
|
{tab.count}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</TabsTrigger>
|
||||||
|
))}
|
||||||
|
</TabsList>
|
||||||
|
{children}
|
||||||
|
</Tabs>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -8,22 +8,13 @@ import {
|
|||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { dashboardKpiGridClassName, kpiCardContentClassName } from '@/lib/ui-surface'
|
|
||||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
|
||||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
|
||||||
|
|
||||||
import { aggregateNetworkMetrics, runningJobCount } from '@/queries/overview'
|
import { aggregateNetworkMetrics, runningJobCount } from '@/queries/overview'
|
||||||
import type { JobRow, ModuleRow, PeerRow, SpeakerRow } from '@/types/api'
|
import type { JobRow, ModuleRow, PeerRow, SpeakerRow } from '@/types/api'
|
||||||
|
|
||||||
type KpiCard = {
|
type KpiCard = KpiStatItem & { icon: ReactNode }
|
||||||
icon: ReactNode
|
|
||||||
iconClass: string
|
|
||||||
value: string
|
|
||||||
label: string
|
|
||||||
badge: ReactNode
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildKpis({
|
function buildKpis({
|
||||||
modules,
|
modules,
|
||||||
@@ -52,22 +43,24 @@ function buildKpis({
|
|||||||
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
|
id: 'modules',
|
||||||
icon: <Boxes aria-hidden />,
|
icon: <Boxes aria-hidden />,
|
||||||
iconClass: 'text-primary',
|
iconClassName: 'text-primary',
|
||||||
value: loading ? '—' : `${enabledModules}/${modules.length || 0}`,
|
value: loading ? '—' : `${enabledModules}/${modules.length || 0}`,
|
||||||
label: 'Модули активны',
|
label: 'Модули активны',
|
||||||
badge: (
|
footer: (
|
||||||
<Badge variant="primary-light" size="sm">
|
<Badge variant="primary-light" size="sm">
|
||||||
{loading ? '…' : `${modules.length} всего`}
|
{loading ? '…' : `${modules.length} всего`}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 'bgp',
|
||||||
icon: <Network aria-hidden />,
|
icon: <Network aria-hidden />,
|
||||||
iconClass: 'text-info',
|
iconClassName: 'text-info',
|
||||||
value: loading || bgpPct === null ? '—' : `${bgpPct}%`,
|
value: loading || bgpPct === null ? '—' : `${bgpPct}%`,
|
||||||
label: 'BGP готовность',
|
label: 'BGP готовность',
|
||||||
badge: (
|
footer: (
|
||||||
<Badge
|
<Badge
|
||||||
variant={
|
variant={
|
||||||
bgpPct !== null && bgpPct >= 90
|
bgpPct !== null && bgpPct >= 90
|
||||||
@@ -85,22 +78,24 @@ function buildKpis({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 'peers',
|
||||||
icon: <Share2 aria-hidden />,
|
icon: <Share2 aria-hidden />,
|
||||||
iconClass: 'text-success',
|
iconClassName: 'text-success',
|
||||||
value: loading ? '—' : `${network.peersEstablished}/${peersEnabled}`,
|
value: loading ? '—' : `${network.peersEstablished}/${peersEnabled}`,
|
||||||
label: 'Пиры Established',
|
label: 'Пиры Established',
|
||||||
badge: (
|
footer: (
|
||||||
<Badge variant="success-light" size="sm">
|
<Badge variant="success-light" size="sm">
|
||||||
{loading ? '…' : `${network.peersTotal} в каталоге`}
|
{loading ? '…' : `${network.peersTotal} в каталоге`}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 'speakers',
|
||||||
icon: <ServerCog aria-hidden />,
|
icon: <ServerCog aria-hidden />,
|
||||||
iconClass: 'text-warning',
|
iconClassName: 'text-warning',
|
||||||
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
|
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
|
||||||
label: 'Спикеры online',
|
label: 'Спикеры online',
|
||||||
badge: (
|
footer: (
|
||||||
<Badge
|
<Badge
|
||||||
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
|
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -110,22 +105,25 @@ function buildKpis({
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 'jobs',
|
||||||
icon: <ListChecks aria-hidden />,
|
icon: <ListChecks aria-hidden />,
|
||||||
iconClass: 'text-focus',
|
iconClassName: 'text-focus',
|
||||||
value: loading ? '—' : String(running),
|
value: loading ? '—' : String(running),
|
||||||
label: 'Активные задачи',
|
label: 'Активные задачи',
|
||||||
badge: (
|
footer: (
|
||||||
<Badge variant={running > 0 ? 'info-light' : 'outline'} size="sm">
|
<Badge variant={running > 0 ? 'info-light' : 'outline'} size="sm">
|
||||||
{loading ? '…' : `${jobs.length} в выборке`}
|
{loading ? '…' : `${jobs.length} в выборке`}
|
||||||
</Badge>
|
</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: 'risks',
|
||||||
icon: <AlertTriangle aria-hidden />,
|
icon: <AlertTriangle aria-hidden />,
|
||||||
iconClass: 'text-destructive',
|
iconClassName: 'text-destructive',
|
||||||
value: loading ? '—' : String(riskCount),
|
value: loading ? '—' : String(riskCount),
|
||||||
label: 'Риски',
|
label: 'Риски',
|
||||||
badge: (
|
variant: riskCount > 0 ? 'destructive' : 'default',
|
||||||
|
footer: (
|
||||||
<Badge variant={riskCount > 0 ? 'destructive-light' : 'success-light'} size="sm">
|
<Badge variant={riskCount > 0 ? 'destructive-light' : 'success-light'} size="sm">
|
||||||
{loading
|
{loading
|
||||||
? '…'
|
? '…'
|
||||||
@@ -151,33 +149,10 @@ export function DashboardKpiGrid({
|
|||||||
jobs: JobRow[]
|
jobs: JobRow[]
|
||||||
loading?: boolean
|
loading?: boolean
|
||||||
}) {
|
}) {
|
||||||
const cards = buildKpis({ modules, peers, speakers, jobs, loading })
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section aria-label="KPI обзора" className={dashboardKpiGridClassName}>
|
<KpiStatGrid
|
||||||
{cards.map((card) => (
|
items={buildKpis({ modules, peers, speakers, jobs, loading })}
|
||||||
<Card key={card.label} size="sm" className="gap-0">
|
aria-label="KPI обзора"
|
||||||
<CardContent className={cn(kpiCardContentClassName, 'gap-3 p-4')}>
|
/>
|
||||||
<Item
|
|
||||||
className={cn(
|
|
||||||
'border-background bg-muted flex size-9 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
|
||||||
card.iconClass,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ItemMedia variant="icon" className="size-auto">
|
|
||||||
{card.icon}
|
|
||||||
</ItemMedia>
|
|
||||||
</Item>
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<div className="text-foreground text-xl leading-none font-bold tabular-nums">
|
|
||||||
{card.value}
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground text-xs font-medium">{card.label}</div>
|
|
||||||
</div>
|
|
||||||
{card.badge}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
import { Link } from '@tanstack/react-router'
|
|
||||||
import { ChevronRight } from 'lucide-react'
|
|
||||||
import type { ReactNode } from 'react'
|
|
||||||
|
|
||||||
import { CardDotField } from '@/components/dashboard/card-dot-field'
|
|
||||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
|
||||||
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
|
||||||
|
|
||||||
export type QuickLinkCardProps = {
|
|
||||||
icon: ReactNode
|
|
||||||
label: string
|
|
||||||
description: string
|
|
||||||
to: string
|
|
||||||
search?: Record<string, string>
|
|
||||||
iconClass: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DashboardQuickLinkCard({
|
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
description,
|
|
||||||
to,
|
|
||||||
search,
|
|
||||||
iconClass,
|
|
||||||
}: QuickLinkCardProps) {
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
to={to}
|
|
||||||
search={search}
|
|
||||||
className="group block h-full rounded-[inherit] focus-visible:outline-none"
|
|
||||||
aria-label={`${label}: ${description}`}
|
|
||||||
>
|
|
||||||
<Card
|
|
||||||
size="sm"
|
|
||||||
className={cn(
|
|
||||||
'relative isolate h-full overflow-hidden transition-colors',
|
|
||||||
'hover:border-foreground/20',
|
|
||||||
'group-focus-visible:ring-2 group-focus-visible:ring-ring group-focus-visible:ring-offset-2 group-focus-visible:ring-offset-background',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CardDotField className="text-muted-foreground [mask-image:linear-gradient(to_bottom_left,black,transparent_60%)]" />
|
|
||||||
<CardContent className="relative z-10 flex h-full flex-col gap-7.5 p-5">
|
|
||||||
<Item
|
|
||||||
className={cn(
|
|
||||||
'border-background flex size-11 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-5',
|
|
||||||
iconClass,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ItemMedia variant="icon" className="size-auto">
|
|
||||||
{icon}
|
|
||||||
</ItemMedia>
|
|
||||||
</Item>
|
|
||||||
<div className="mt-auto flex flex-col gap-3">
|
|
||||||
<span className="text-foreground block text-sm leading-tight font-medium">{label}</span>
|
|
||||||
<p className="text-muted-foreground text-xs leading-relaxed">{description}</p>
|
|
||||||
<span className="text-primary inline-flex items-center gap-1 text-xs font-medium underline-offset-2 group-hover:underline">
|
|
||||||
Перейти
|
|
||||||
<ChevronRight
|
|
||||||
aria-hidden
|
|
||||||
className="size-2.5 shrink-0 transition-transform group-hover:translate-x-0.5"
|
|
||||||
/>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</Link>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,78 +1,76 @@
|
|||||||
import { Gauge, Network, Play, Plus, Share2, Tags } from 'lucide-react'
|
import { Gauge, Network, Play, Plus, Search, Share2, Tags } from 'lucide-react'
|
||||||
import type { ReactNode } from 'react'
|
|
||||||
|
|
||||||
import { DashboardQuickLinkCard } from '@/components/dashboard/dashboard-quick-link-card'
|
import { QuickActionGrid, type QuickActionItem } from '@/components/reui-kit'
|
||||||
import { PanelCard } from '@/components/panel-card'
|
|
||||||
|
|
||||||
type QuickLink = {
|
/** iconClassName: semantic text only (shared chrome with CFDM). @see https://reui.io/preview/base/stats-12 */
|
||||||
icon: ReactNode
|
const ACTIONS: QuickActionItem[] = [
|
||||||
label: string
|
|
||||||
description: string
|
|
||||||
to: string
|
|
||||||
search?: Record<string, string>
|
|
||||||
iconClass: string
|
|
||||||
}
|
|
||||||
|
|
||||||
const LINKS: QuickLink[] = [
|
|
||||||
{
|
{
|
||||||
icon: <Plus aria-hidden />,
|
id: 'lookup',
|
||||||
label: 'Создать модуль',
|
title: 'Проверка IP/домена',
|
||||||
|
description: 'Membership в списках и community (entry + snapshot).',
|
||||||
|
to: '/lookup',
|
||||||
|
icon: <Search aria-hidden />,
|
||||||
|
iconClassName: 'text-primary',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'new-module',
|
||||||
|
title: 'Создать модуль',
|
||||||
description: 'Новый модуль маршрутизации и источники префиксов.',
|
description: 'Новый модуль маршрутизации и источники префиксов.',
|
||||||
to: '/modules/new',
|
to: '/modules/new',
|
||||||
iconClass: 'bg-primary text-primary-foreground [&_svg]:text-primary-foreground',
|
icon: <Plus aria-hidden />,
|
||||||
|
iconClassName: 'text-primary',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Tags aria-hidden />,
|
id: 'communities',
|
||||||
label: 'BGP-сообщества',
|
title: 'BGP-сообщества',
|
||||||
description: 'Справочник communities для политик экспорта.',
|
description: 'Справочник communities для политик экспорта.',
|
||||||
to: '/directories',
|
to: '/directories',
|
||||||
iconClass: 'bg-info text-info-foreground [&_svg]:text-info-foreground',
|
icon: <Tags aria-hidden />,
|
||||||
|
iconClassName: 'text-info',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Network aria-hidden />,
|
id: 'network',
|
||||||
label: 'Сеть',
|
title: 'Сеть',
|
||||||
description: 'Обзор пиров, спикеров и live-сессий BGP.',
|
description: 'Обзор пиров, спикеров и live-сессий BGP.',
|
||||||
to: '/network',
|
to: '/network',
|
||||||
search: { tab: 'overview' },
|
search: { tab: 'overview' },
|
||||||
iconClass: 'bg-success text-success-foreground [&_svg]:text-success-foreground',
|
icon: <Network aria-hidden />,
|
||||||
|
iconClassName: 'text-success',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Share2 aria-hidden />,
|
id: 'add-peer',
|
||||||
label: 'Добавить пира',
|
title: 'Добавить пира',
|
||||||
description: 'Настройка BGP-соседа и шаблонов сессии.',
|
description: 'Настройка BGP-соседа и шаблонов сессии.',
|
||||||
to: '/network',
|
to: '/network',
|
||||||
search: { tab: 'peers' },
|
search: { tab: 'peers' },
|
||||||
iconClass: 'bg-warning text-warning-foreground [&_svg]:text-warning-foreground',
|
icon: <Share2 aria-hidden />,
|
||||||
|
iconClassName: 'text-warning',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Play aria-hidden />,
|
id: 'deploy',
|
||||||
label: 'Деплой',
|
title: 'Деплой',
|
||||||
description: 'Ревизии конфигурации и применение на нодах.',
|
description: 'Ревизии конфигурации и применение на нодах.',
|
||||||
to: '/operations',
|
to: '/operations',
|
||||||
search: { tab: 'revisions' },
|
search: { tab: 'revisions' },
|
||||||
iconClass: 'bg-focus text-focus-foreground [&_svg]:text-focus-foreground',
|
icon: <Play aria-hidden />,
|
||||||
|
iconClassName: 'text-muted-foreground',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
icon: <Gauge aria-hidden />,
|
id: 'monitoring',
|
||||||
label: 'Мониторинг',
|
title: 'Мониторинг',
|
||||||
description: 'Состояние системы, BIRD и PostgreSQL.',
|
description: 'Состояние системы, BIRD и PostgreSQL.',
|
||||||
to: '/monitoring',
|
to: '/monitoring',
|
||||||
search: { tab: 'system' },
|
search: { tab: 'system' },
|
||||||
iconClass: 'bg-destructive text-destructive-foreground [&_svg]:text-destructive-foreground',
|
icon: <Gauge aria-hidden />,
|
||||||
|
iconClassName: 'text-destructive',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export function DashboardQuickLinks() {
|
export function DashboardQuickLinks() {
|
||||||
return (
|
return (
|
||||||
<PanelCard
|
<QuickActionGrid
|
||||||
title="Быстрые действия"
|
actions={ACTIONS}
|
||||||
description="Частые переходы к настройке и деплою"
|
description="Частые переходы к настройке и деплою"
|
||||||
className="@container w-full"
|
/>
|
||||||
contentClassName="grid gap-3 p-4 sm:grid-cols-2 xl:grid-cols-3"
|
|
||||||
>
|
|
||||||
{LINKS.map((link) => (
|
|
||||||
<DashboardQuickLinkCard key={link.label} {...link} />
|
|
||||||
))}
|
|
||||||
</PanelCard>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,15 @@ import type { Table } from '@tanstack/react-table'
|
|||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
|
||||||
import { DataGridToolbar } from '@/components/data-grid-toolbar'
|
import { DataGridToolbar } from '@/components/data-grid-toolbar'
|
||||||
import { PanelCard, panelCardContentFlushClassName, panelCardFooterClassName, panelCardInsetClassName } from '@/components/panel-card'
|
import {
|
||||||
|
PanelCard,
|
||||||
|
panelCardContentFlushClassName,
|
||||||
|
panelCardFooterClassName,
|
||||||
|
} from '@/components/panel-card'
|
||||||
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||||
import { DataGrid, DataGridContainer } from '@/components/reui/data-grid/data-grid'
|
import { DataGrid, DataGridContainer } from '@/components/reui/data-grid/data-grid'
|
||||||
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||||
|
import { FrameFooter } from '@/components/reui/frame'
|
||||||
import {
|
import {
|
||||||
DATA_GRID_MESSAGES_RU,
|
DATA_GRID_MESSAGES_RU,
|
||||||
DATA_GRID_PAGINATION_RU,
|
DATA_GRID_PAGINATION_RU,
|
||||||
@@ -52,9 +57,9 @@ export function DataGridShell<TData extends object>({
|
|||||||
<DataGridTable />
|
<DataGridTable />
|
||||||
</DataGridContainer>
|
</DataGridContainer>
|
||||||
{showPagination ? (
|
{showPagination ? (
|
||||||
<div className={cn(panelCardFooterClassName, panelCardInsetClassName)}>
|
<FrameFooter className={cn(panelCardFooterClassName)}>
|
||||||
<DataGridPagination {...DATA_GRID_PAGINATION_RU} />
|
<DataGridPagination {...DATA_GRID_PAGINATION_RU} />
|
||||||
</div>
|
</FrameFooter>
|
||||||
) : null}
|
) : null}
|
||||||
</DataGrid>
|
</DataGrid>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -16,12 +16,13 @@ interface DataGridToolbarProps {
|
|||||||
searchValue: string
|
searchValue: string
|
||||||
onSearchChange: (value: string) => void
|
onSearchChange: (value: string) => void
|
||||||
searchPlaceholder?: string
|
searchPlaceholder?: string
|
||||||
|
/** ReUI Filters trigger or custom filter controls. Preview: https://reui.io/preview/base/data-grid-filtering-2 */
|
||||||
filters?: ReactNode
|
filters?: ReactNode
|
||||||
actions?: ReactNode
|
actions?: ReactNode
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Search row for data grids (ReUI c-input-group-37 pattern). */
|
/** Search + optional ReUI Filters row for Frame data grids. */
|
||||||
export function DataGridToolbar({
|
export function DataGridToolbar({
|
||||||
searchValue,
|
searchValue,
|
||||||
onSearchChange,
|
onSearchChange,
|
||||||
@@ -58,19 +59,19 @@ export function DataGridToolbar({
|
|||||||
filters
|
filters
|
||||||
) : (
|
) : (
|
||||||
<InputGroupButton
|
<InputGroupButton
|
||||||
|
aria-label="Фильтры"
|
||||||
|
size="icon-xs"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="xs"
|
disabled
|
||||||
className="pointer-events-none gap-1.5 opacity-0"
|
title="Передайте filters={<Filters … />} из @/components/reui/filters"
|
||||||
aria-hidden="true"
|
|
||||||
tabIndex={-1}
|
|
||||||
>
|
>
|
||||||
<ListFilterIcon className="size-3.5" />
|
<ListFilterIcon aria-hidden="true" />
|
||||||
</InputGroupButton>
|
</InputGroupButton>
|
||||||
)}
|
)}
|
||||||
</InputGroupAddon>
|
</InputGroupAddon>
|
||||||
</InputGroup>
|
</InputGroup>
|
||||||
</Field>
|
</Field>
|
||||||
{actions ? <div className="flex shrink-0 items-center gap-2">{actions}</div> : null}
|
{actions ? <div className="flex shrink-0 flex-wrap items-center gap-2">{actions}</div> : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import { Button } from "@evobgp/ui/components/button"
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardFooter,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@evobgp/ui/components/card"
|
||||||
|
import { Input } from "@evobgp/ui/components/input"
|
||||||
|
import { Label } from "@evobgp/ui/components/label"
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from "@evobgp/ui/components/tabs"
|
||||||
|
|
||||||
|
export function Pattern() {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full max-w-xs flex-col gap-6">
|
||||||
|
<Tabs defaultValue="account">
|
||||||
|
<TabsList variant="line" className="mb-3.5 w-full">
|
||||||
|
<TabsTrigger value="account">Account</TabsTrigger>
|
||||||
|
<TabsTrigger value="password">Password</TabsTrigger>
|
||||||
|
<TabsTrigger value="settings">Settings</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="account">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">Account</CardTitle>
|
||||||
|
<CardDescription className="text-sm">
|
||||||
|
Update your account information.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="underline-name" className="text-sm">
|
||||||
|
Name
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="underline-name"
|
||||||
|
defaultValue="Alex Chen"
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="underline-email" className="text-sm">
|
||||||
|
Email
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="underline-email"
|
||||||
|
type="email"
|
||||||
|
defaultValue="[email protected]"
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="pt-3">
|
||||||
|
<Button size="sm">Save changes</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="password">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">Password</CardTitle>
|
||||||
|
<CardDescription className="text-sm">
|
||||||
|
Change your password here.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="underline-current" className="text-sm">
|
||||||
|
Current password
|
||||||
|
</Label>
|
||||||
|
<Input id="underline-current" type="password" className="h-9" />
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="underline-new" className="text-sm">
|
||||||
|
New password
|
||||||
|
</Label>
|
||||||
|
<Input id="underline-new" type="password" className="h-9" />
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="pt-3">
|
||||||
|
<Button size="sm">Update password</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="settings">
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-base">Settings</CardTitle>
|
||||||
|
<CardDescription className="text-sm">
|
||||||
|
Manage your preferences.
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="underline-theme" className="text-sm">
|
||||||
|
Theme
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="underline-theme"
|
||||||
|
defaultValue="Light"
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="underline-language" className="text-sm">
|
||||||
|
Language
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="underline-language"
|
||||||
|
defaultValue="English"
|
||||||
|
className="h-9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
<CardFooter className="pt-3">
|
||||||
|
<Button size="sm">Save settings</Button>
|
||||||
|
</CardFooter>
|
||||||
|
</Card>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,195 +0,0 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
|
||||||
|
|
||||||
import { DataGridMutedCell, DataGridPrimaryCell } from '@/components/data-grid-cell'
|
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
|
||||||
import type { FirewallClient } from '@/types/api'
|
|
||||||
|
|
||||||
function formatPacketCount(value?: number | null): string | null {
|
|
||||||
if (value == null || value <= 0) return null
|
|
||||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`
|
|
||||||
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}k`
|
|
||||||
return String(value)
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FirewallClientsGridProps {
|
|
||||||
clients: FirewallClient[]
|
|
||||||
isLoading?: boolean
|
|
||||||
onApprove: (id: string) => void
|
|
||||||
onReject: (id: string) => void
|
|
||||||
approvePending?: boolean
|
|
||||||
rejectPending?: boolean
|
|
||||||
emptyTitle?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FirewallClientsGrid({
|
|
||||||
clients,
|
|
||||||
isLoading = false,
|
|
||||||
onApprove,
|
|
||||||
onReject,
|
|
||||||
approvePending = false,
|
|
||||||
rejectPending = false,
|
|
||||||
emptyTitle = 'Нет клиентов',
|
|
||||||
}: FirewallClientsGridProps) {
|
|
||||||
const columns = useMemo<ColumnDef<FirewallClient>[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
accessorKey: 'name',
|
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<DataGridPrimaryCell
|
|
||||||
title={row.original.name}
|
|
||||||
subtitle={row.original.hostname || row.original.token_prefix}
|
|
||||||
accent="primary"
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
meta: { headerTitle: 'Имя' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'status',
|
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Статус" />,
|
|
||||||
cell: ({ row }) => <StatusBadge status={row.original.status} />,
|
|
||||||
meta: { headerTitle: 'Статус' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'last_seen_at',
|
|
||||||
accessorFn: (row) => row.last_seen_at ?? '',
|
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Последняя активность" />,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<DataGridMutedCell>{row.original.last_seen_at?.slice(0, 19) ?? '—'}</DataGridMutedCell>
|
|
||||||
),
|
|
||||||
sortingFn: (a, b) => {
|
|
||||||
const av = a.original.last_seen_at ?? ''
|
|
||||||
const bv = b.original.last_seen_at ?? ''
|
|
||||||
return av.localeCompare(bv)
|
|
||||||
},
|
|
||||||
meta: { headerTitle: 'Последняя активность' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'apply',
|
|
||||||
enableSorting: false,
|
|
||||||
header: 'Применение',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const c = row.original
|
|
||||||
return (
|
|
||||||
<span className="text-xs">
|
|
||||||
{c.last_apply_status ?? '—'}
|
|
||||||
{c.last_apply_prefix_count != null ? ` (${c.last_apply_prefix_count})` : ''}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
meta: { headerTitle: 'Применение' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'packets',
|
|
||||||
enableSorting: false,
|
|
||||||
header: 'Пакеты',
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const dropped = formatPacketCount(row.original.last_apply_packets_dropped)
|
|
||||||
const accepted = formatPacketCount(row.original.last_apply_packets_accepted)
|
|
||||||
if (!dropped && !accepted) {
|
|
||||||
return <span className="text-muted-foreground text-xs">—</span>
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<span className="text-muted-foreground text-xs">
|
|
||||||
{dropped ? <span className="text-destructive">↓{dropped}</span> : null}
|
|
||||||
{dropped && accepted ? ' · ' : null}
|
|
||||||
{accepted ? <span className="text-success">↑{accepted}</span> : null}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
meta: { headerTitle: 'Пакеты' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
enableSorting: false,
|
|
||||||
header: () => null,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const c = row.original
|
|
||||||
return (
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
{c.status === 'pending' ? (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
type="button"
|
|
||||||
disabled={approvePending}
|
|
||||||
onClick={() => onApprove(c.id)}
|
|
||||||
>
|
|
||||||
Одобрить
|
|
||||||
</Button>
|
|
||||||
<ConfirmDialog
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
type="button"
|
|
||||||
className="text-destructive"
|
|
||||||
disabled={rejectPending}
|
|
||||||
>
|
|
||||||
Отклонить
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
title="Отклонить запрос?"
|
|
||||||
description={`${c.name}${c.hostname ? ` (${c.hostname})` : ''} — запись будет удалена, токен перестанет работать.`}
|
|
||||||
confirmLabel="Отклонить"
|
|
||||||
destructive
|
|
||||||
onConfirm={() => onReject(c.id)}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
{c.status === 'approved' ? (
|
|
||||||
<ConfirmDialog
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
type="button"
|
|
||||||
className="text-destructive"
|
|
||||||
disabled={rejectPending}
|
|
||||||
>
|
|
||||||
Удалить
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
title="Удалить клиент?"
|
|
||||||
description={`${c.name} — запись будет удалена, blocklist и токен перестанут работать.`}
|
|
||||||
confirmLabel="Удалить"
|
|
||||||
destructive
|
|
||||||
onConfirm={() => onReject(c.id)}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[approvePending, onApprove, onReject, rejectPending],
|
|
||||||
)
|
|
||||||
|
|
||||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
|
||||||
data: clients,
|
|
||||||
columns,
|
|
||||||
getSearchText: (row) =>
|
|
||||||
`${row.name} ${row.hostname ?? ''} ${row.token_prefix} ${row.status ?? ''}`,
|
|
||||||
getRowId: (row) => row.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridSection
|
|
||||||
table={table}
|
|
||||||
recordCount={filteredCount}
|
|
||||||
isLoading={isLoading}
|
|
||||||
emptyMessage={emptyTitle}
|
|
||||||
searchValue={globalFilter}
|
|
||||||
onSearchChange={setGlobalFilter}
|
|
||||||
searchPlaceholder="Поиск клиентов…"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import { useEffect, useState } from 'react'
|
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
|
||||||
import { Input } from '@evobgp/ui/components/input'
|
|
||||||
import { Label } from '@evobgp/ui/components/label'
|
|
||||||
|
|
||||||
import { FormDrawer } from '@/components/form-drawer'
|
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
|
||||||
import { CommunitySelect } from '@/components/modules/community-select'
|
|
||||||
import { SelectField } from '@/components/select-field'
|
|
||||||
import { useCreateFirewallRule } from '@/queries/firewall'
|
|
||||||
import type { BgpCommunity } from '@/types/api'
|
|
||||||
|
|
||||||
const FIREWALL_ACTION_ITEMS = [
|
|
||||||
{ value: 'block', label: 'block' },
|
|
||||||
{ value: 'accept', label: 'accept' },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
interface FirewallRuleCreateDialogProps {
|
|
||||||
open: boolean
|
|
||||||
onOpenChange: (open: boolean) => void
|
|
||||||
communities: BgpCommunity[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FirewallRuleCreateDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
communities,
|
|
||||||
}: FirewallRuleCreateDialogProps) {
|
|
||||||
const createMutation = useCreateFirewallRule()
|
|
||||||
const [action, setAction] = useState<'block' | 'accept'>('block')
|
|
||||||
const [communityId, setCommunityId] = useState<string | null>(null)
|
|
||||||
const [comment, setComment] = useState('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) return
|
|
||||||
setAction('block')
|
|
||||||
setCommunityId(null)
|
|
||||||
setComment('')
|
|
||||||
}, [open])
|
|
||||||
|
|
||||||
async function save() {
|
|
||||||
try {
|
|
||||||
await createMutation.mutateAsync({
|
|
||||||
scope: 'tenant',
|
|
||||||
action,
|
|
||||||
community_id: communityId,
|
|
||||||
comment: comment.trim(),
|
|
||||||
})
|
|
||||||
onOpenChange(false)
|
|
||||||
} catch {
|
|
||||||
// toast handled in mutation
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FormDrawer
|
|
||||||
open={open}
|
|
||||||
onOpenChange={onOpenChange}
|
|
||||||
title="Новое правило"
|
|
||||||
className="sm:max-w-md"
|
|
||||||
footer={
|
|
||||||
<>
|
|
||||||
<Button variant="outline" type="button" onClick={() => onOpenChange(false)}>
|
|
||||||
Отмена
|
|
||||||
</Button>
|
|
||||||
<LoadingButton type="button" onClick={save} loading={createMutation.isPending}>
|
|
||||||
Добавить
|
|
||||||
</LoadingButton>
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectField
|
|
||||||
id="fw-rule-action"
|
|
||||||
label="Действие"
|
|
||||||
items={[...FIREWALL_ACTION_ITEMS]}
|
|
||||||
value={action}
|
|
||||||
placeholder="Выберите действие"
|
|
||||||
onValueChange={(v) => v && setAction(v as 'block' | 'accept')}
|
|
||||||
/>
|
|
||||||
<CommunitySelect
|
|
||||||
id="fw-rule-community"
|
|
||||||
label="Community"
|
|
||||||
value={communityId}
|
|
||||||
onValueChange={setCommunityId}
|
|
||||||
communities={communities}
|
|
||||||
nullable
|
|
||||||
placeholder="Все communities"
|
|
||||||
/>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="fw-rule-comment">Комментарий</Label>
|
|
||||||
<Input
|
|
||||||
id="fw-rule-comment"
|
|
||||||
placeholder="Комментарий"
|
|
||||||
value={comment}
|
|
||||||
onChange={(e) => setComment(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</FormDrawer>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
|
||||||
import { useMemo } from 'react'
|
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
|
||||||
|
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
|
||||||
import { ConfirmDialog } from '@/components/confirm-dialog'
|
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
|
||||||
import { communityLabel } from '@/lib/modules/helpers'
|
|
||||||
import type { BgpCommunity, FirewallRule } from '@/types/api'
|
|
||||||
|
|
||||||
export interface FirewallRulesGridProps {
|
|
||||||
rules: FirewallRule[]
|
|
||||||
communities: BgpCommunity[]
|
|
||||||
isLoading?: boolean
|
|
||||||
onDelete: (id: string) => void
|
|
||||||
deletePending?: boolean
|
|
||||||
emptyTitle?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FirewallRulesGrid({
|
|
||||||
rules,
|
|
||||||
communities,
|
|
||||||
isLoading = false,
|
|
||||||
onDelete,
|
|
||||||
deletePending = false,
|
|
||||||
emptyTitle = 'Нет правил — blocklist пуст (default accept).',
|
|
||||||
}: FirewallRulesGridProps) {
|
|
||||||
const columns = useMemo<ColumnDef<FirewallRule>[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
accessorKey: 'priority',
|
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="#" />,
|
|
||||||
cell: ({ row }) => row.original.priority,
|
|
||||||
meta: { headerTitle: '#' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'action',
|
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Действие" />,
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<StatusBadge status={row.original.action} label={row.original.action} />
|
|
||||||
),
|
|
||||||
meta: { headerTitle: 'Действие' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'community',
|
|
||||||
enableSorting: false,
|
|
||||||
header: 'Community',
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<span className="text-sm">
|
|
||||||
{row.original.community_id
|
|
||||||
? communityLabel(row.original.community_id, communities)
|
|
||||||
: 'Все'}
|
|
||||||
</span>
|
|
||||||
),
|
|
||||||
meta: { headerTitle: 'Community' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: 'comment',
|
|
||||||
enableSorting: false,
|
|
||||||
header: 'Комментарий',
|
|
||||||
cell: ({ row }) => row.original.comment || '—',
|
|
||||||
meta: { headerTitle: 'Комментарий' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'actions',
|
|
||||||
enableSorting: false,
|
|
||||||
header: () => null,
|
|
||||||
cell: ({ row }) => {
|
|
||||||
const r = row.original
|
|
||||||
return (
|
|
||||||
<ConfirmDialog
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
type="button"
|
|
||||||
className="text-destructive"
|
|
||||||
disabled={deletePending}
|
|
||||||
>
|
|
||||||
Удалить
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
title="Удалить правило?"
|
|
||||||
description={
|
|
||||||
r.comment
|
|
||||||
? `Правило #${r.priority} (${r.action}): ${r.comment}`
|
|
||||||
: `Правило #${r.priority} (${r.action}) будет удалено.`
|
|
||||||
}
|
|
||||||
confirmLabel="Удалить"
|
|
||||||
destructive
|
|
||||||
onConfirm={() => onDelete(r.id)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[communities, deletePending, onDelete],
|
|
||||||
)
|
|
||||||
|
|
||||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
|
||||||
data: rules,
|
|
||||||
columns,
|
|
||||||
getSearchText: (row) =>
|
|
||||||
`${row.priority} ${row.action} ${row.comment ?? ''} ${communityLabel(row.community_id, communities)}`,
|
|
||||||
getRowId: (row) => row.id,
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DataGridSection
|
|
||||||
table={table}
|
|
||||||
recordCount={filteredCount}
|
|
||||||
isLoading={isLoading}
|
|
||||||
emptyMessage={emptyTitle}
|
|
||||||
searchValue={globalFilter}
|
|
||||||
onSearchChange={setGlobalFilter}
|
|
||||||
searchPlaceholder="Поиск правил…"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Re-export from reui-kit — keep `@/components/kpi-stat-grid` imports stable.
|
||||||
|
* @see https://reui.io/preview/base/stats-12
|
||||||
|
*/
|
||||||
|
export {
|
||||||
|
KpiStatGrid,
|
||||||
|
KpiStatCard,
|
||||||
|
KpiStatCardTile,
|
||||||
|
kpiStatItemKey,
|
||||||
|
type KpiStatItem,
|
||||||
|
type KpiStatCardData,
|
||||||
|
type KpiStatVariant,
|
||||||
|
type OpsKpiCard,
|
||||||
|
} from '@/components/reui-kit/kpi-stat-grid'
|
||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
BookText,
|
BookText,
|
||||||
KeyRound,
|
KeyRound,
|
||||||
ServerCog,
|
ServerCog,
|
||||||
Shield,
|
Search,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -41,9 +41,13 @@ import { TooltipProvider } from '@evobgp/ui/components/tooltip'
|
|||||||
import { Link, useRouterState } from '@tanstack/react-router'
|
import { Link, useRouterState } from '@tanstack/react-router'
|
||||||
import type { ComponentType, CSSProperties, ReactNode } from 'react'
|
import type { ComponentType, CSSProperties, ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { AppSwitcher } from '@/components/app-switcher'
|
||||||
|
import { AppsMenu } from '@/components/layout/apps-menu'
|
||||||
import { CommandPalette, type CommandPaletteItem } from '@/components/layout/command-palette'
|
import { CommandPalette, type CommandPaletteItem } from '@/components/layout/command-palette'
|
||||||
|
import { NavUser } from '@/components/layout/nav-user'
|
||||||
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
import { SystemMonitorPopover } from '@/components/layout/system-monitor-popover'
|
||||||
import { ModeToggle } from '@/components/mode-toggle'
|
import { ModeToggle } from '@/components/mode-toggle'
|
||||||
|
import { can, isAuthEnabled, permissionForPath } from '@/lib/auth'
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
to: string
|
to: string
|
||||||
@@ -51,6 +55,8 @@ interface NavItem {
|
|||||||
icon: ComponentType<{ className?: string }>
|
icon: ComponentType<{ className?: string }>
|
||||||
description?: string
|
description?: string
|
||||||
search?: Record<string, string>
|
search?: Record<string, string>
|
||||||
|
/** Explicit permission override; when omitted derived from `to`. */
|
||||||
|
permission?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavGroup {
|
interface NavGroup {
|
||||||
@@ -74,6 +80,7 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
label: 'Маршрутизация',
|
label: 'Маршрутизация',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/modules', label: 'Модули', icon: Boxes, description: 'Списки префиксов и AS' },
|
{ to: '/modules', label: 'Модули', icon: Boxes, description: 'Списки префиксов и AS' },
|
||||||
|
{ to: '/lookup', label: 'Проверка', icon: Search, description: 'IP/домен в списках и community' },
|
||||||
{ to: '/network', label: 'Сеть', icon: Network, description: 'BGP-пиры и спикеры', search: { tab: 'overview' } },
|
{ to: '/network', label: 'Сеть', icon: Network, description: 'BGP-пиры и спикеры', search: { tab: 'overview' } },
|
||||||
{ to: '/directories', label: 'Справочники', icon: BookText, description: 'Communities и DoH' },
|
{ to: '/directories', label: 'Справочники', icon: BookText, description: 'Communities и DoH' },
|
||||||
],
|
],
|
||||||
@@ -82,7 +89,6 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
label: 'Операции',
|
label: 'Операции',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и apply', search: { tab: 'revisions' } },
|
{ to: '/operations', label: 'Операции', icon: Cog, description: 'Ревизии и apply', search: { tab: 'revisions' } },
|
||||||
{ to: '/firewall', label: 'Файрвол', icon: Shield, description: 'Клиенты и правила' },
|
|
||||||
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание refresh' },
|
{ to: '/schedule', label: 'Задачи', icon: ListChecks, description: 'Расписание refresh' },
|
||||||
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Health и BIRD', search: { tab: 'system' } },
|
{ to: '/monitoring', label: 'Мониторинг', icon: Activity, description: 'Health и BIRD', search: { tab: 'system' } },
|
||||||
],
|
],
|
||||||
@@ -92,13 +98,25 @@ const NAV_GROUPS: NavGroup[] = [
|
|||||||
items: [
|
items: [
|
||||||
{ to: '/access', label: 'Доступ', icon: KeyRound, description: 'API-ключи' },
|
{ to: '/access', label: 'Доступ', icon: KeyRound, description: 'API-ключи' },
|
||||||
{ to: '/tenant-settings', label: 'Настройки BIRD', icon: ServerCog, description: 'Tenant BIRD config' },
|
{ to: '/tenant-settings', label: 'Настройки BIRD', icon: ServerCog, description: 'Tenant BIRD config' },
|
||||||
{ to: '/settings', label: 'Настройки UI', icon: Settings, description: 'Токен и подключение' },
|
{ to: '/settings', label: 'Настройки UI', icon: Settings, description: 'Токен и подключение', search: { tab: 'connection' } },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const ALL_NAV_ITEMS = NAV_GROUPS.flatMap((g) => g.items)
|
const ALL_NAV_ITEMS = NAV_GROUPS.flatMap((g) => g.items)
|
||||||
|
|
||||||
|
/** Nav visible for the current claim (or all items when portal auth is off). */
|
||||||
|
function useVisibleNavGroups(): NavGroup[] {
|
||||||
|
if (!isAuthEnabled()) return NAV_GROUPS
|
||||||
|
return NAV_GROUPS.map((group) => ({
|
||||||
|
...group,
|
||||||
|
items: group.items.filter((item) => {
|
||||||
|
const perm = item.permission ?? permissionForPath(item.to)
|
||||||
|
return !perm || can(perm)
|
||||||
|
}),
|
||||||
|
})).filter((group) => group.items.length > 0)
|
||||||
|
}
|
||||||
|
|
||||||
const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
|
const ROUTE_LABELS: Record<string, string> = Object.fromEntries(
|
||||||
ALL_NAV_ITEMS.map((i) => [i.to, i.label]),
|
ALL_NAV_ITEMS.map((i) => [i.to, i.label]),
|
||||||
)
|
)
|
||||||
@@ -116,8 +134,14 @@ const COMMAND_ITEMS: CommandPaletteItem[] = ALL_NAV_ITEMS.map((item) => ({
|
|||||||
keywords: [item.to.replace(/^\//, '')],
|
keywords: [item.to.replace(/^\//, '')],
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared ops chrome etalon for CFDM / vps-tracker.
|
||||||
|
* @see https://reui.io/preview/base/app-shell-12
|
||||||
|
* @see docs/ui-design-contract.md — Shared App Shell chrome
|
||||||
|
*/
|
||||||
export function AppShell({ children }: { children: ReactNode }) {
|
export function AppShell({ children }: { children: ReactNode }) {
|
||||||
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
|
const visibleGroups = useVisibleNavGroups()
|
||||||
const activeItem =
|
const activeItem =
|
||||||
ALL_NAV_ITEMS.find((i) => pathname === i.to || (i.to !== '/' && pathname.startsWith(`${i.to}/`))) ??
|
ALL_NAV_ITEMS.find((i) => pathname === i.to || (i.to !== '/' && pathname.startsWith(`${i.to}/`))) ??
|
||||||
ALL_NAV_ITEMS[0]
|
ALL_NAV_ITEMS[0]
|
||||||
@@ -134,29 +158,16 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
<SidebarProvider
|
<SidebarProvider
|
||||||
style={
|
style={
|
||||||
{
|
{
|
||||||
'--sidebar-width': '260px',
|
'--sidebar-width': '240px',
|
||||||
'--sidebar-width-icon': '62px',
|
|
||||||
'--header-height': '56px',
|
|
||||||
} as CSSProperties
|
} as CSSProperties
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Sidebar collapsible="icon">
|
<Sidebar collapsible="icon">
|
||||||
<SidebarHeader className="gap-2">
|
<SidebarHeader>
|
||||||
<div className="flex items-center gap-2 px-2 py-1.5">
|
<AppSwitcher />
|
||||||
<div className="flex size-8 items-center justify-center rounded-md bg-primary text-primary-foreground text-sm font-bold">
|
|
||||||
B
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-col overflow-hidden group-data-[collapsible=icon]:hidden">
|
|
||||||
<span className="truncate text-sm font-semibold">EvoBGP</span>
|
|
||||||
<span className="truncate text-xs text-muted-foreground">Плоскость управления</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="px-2 group-data-[collapsible=icon]:px-0">
|
|
||||||
<CommandPalette items={COMMAND_ITEMS} />
|
|
||||||
</div>
|
|
||||||
</SidebarHeader>
|
</SidebarHeader>
|
||||||
<SidebarContent>
|
<SidebarContent>
|
||||||
{NAV_GROUPS.map((group) => (
|
{visibleGroups.map((group) => (
|
||||||
<SidebarGroup key={group.label}>
|
<SidebarGroup key={group.label}>
|
||||||
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
<SidebarGroupLabel>{group.label}</SidebarGroupLabel>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
@@ -182,11 +193,13 @@ export function AppShell({ children }: { children: ReactNode }) {
|
|||||||
</SidebarGroup>
|
</SidebarGroup>
|
||||||
))}
|
))}
|
||||||
</SidebarContent>
|
</SidebarContent>
|
||||||
<SidebarFooter />
|
<SidebarFooter>
|
||||||
|
<NavUser />
|
||||||
|
</SidebarFooter>
|
||||||
</Sidebar>
|
</Sidebar>
|
||||||
<SidebarInset>
|
<SidebarInset>
|
||||||
<header className="sticky top-0 z-10 flex h-(--header-height) shrink-0 items-center gap-2 border-b bg-background/95 px-4 backdrop-blur supports-[backdrop-filter]:bg-background/80">
|
<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 />
|
<SidebarTrigger className="-ml-1" />
|
||||||
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
<Separator orientation="vertical" className="mr-2 data-[orientation=vertical]:h-4" />
|
||||||
<Breadcrumb>
|
<Breadcrumb>
|
||||||
<BreadcrumbList>
|
<BreadcrumbList>
|
||||||
@@ -204,12 +217,16 @@ 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 />
|
||||||
<ModeToggle />
|
<ModeToggle />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main className="flex flex-1 flex-col gap-4 p-4 md:gap-6 md:p-6">{children}</main>
|
<main className="flex flex-1 flex-col gap-4 px-4 py-4 md:gap-6 md:px-6 md:py-5">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
</SidebarInset>
|
</SidebarInset>
|
||||||
|
<CommandPalette items={COMMAND_ITEMS} hotkeyOnly />
|
||||||
</SidebarProvider>
|
</SidebarProvider>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import { LayoutGridIcon } from 'lucide-react'
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@evobgp/ui/components/dropdown-menu'
|
||||||
|
import {
|
||||||
|
APP_SWITCHER_ICONS,
|
||||||
|
CURRENT_APP_ID,
|
||||||
|
} from '@/lib/app-switcher-config'
|
||||||
|
import { useAppSwitcherConfig } from '@/hooks/use-app-switcher'
|
||||||
|
import { authPortalUrl, isAuthEnabled } from '@/lib/auth'
|
||||||
|
|
||||||
|
/** Header apps grid — app-shell-12 AppsMenu. @see https://reui.io/preview/base/app-shell-12 */
|
||||||
|
export function AppsMenu() {
|
||||||
|
const { config, isLoading } = useAppSwitcherConfig()
|
||||||
|
|
||||||
|
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]
|
||||||
|
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={authPortalUrl()} />}
|
||||||
|
className="justify-center text-sm font-medium"
|
||||||
|
>
|
||||||
|
Настроить на портале
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : (
|
||||||
|
<DropdownMenuItem
|
||||||
|
nativeButton={false}
|
||||||
|
render={<Link to="/settings" search={{ tab: 'connection' }} />}
|
||||||
|
className="justify-center text-sm font-medium"
|
||||||
|
>
|
||||||
|
Настройки
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -29,9 +29,11 @@ export type CommandPaletteItem = {
|
|||||||
interface CommandPaletteProps {
|
interface CommandPaletteProps {
|
||||||
items: CommandPaletteItem[]
|
items: CommandPaletteItem[]
|
||||||
className?: string
|
className?: string
|
||||||
|
/** Hotkey-only: no sidebar search trigger (chrome parity with CFDM). */
|
||||||
|
hotkeyOnly?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CommandPalette({ items }: CommandPaletteProps) {
|
export function CommandPalette({ items, hotkeyOnly = false }: CommandPaletteProps) {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const searchInputId = useId()
|
const searchInputId = useId()
|
||||||
@@ -68,25 +70,27 @@ export function CommandPalette({ items }: CommandPaletteProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<SidebarGroup className="p-0">
|
{!hotkeyOnly ? (
|
||||||
<SidebarGroupContent className="relative">
|
<SidebarGroup className="p-0">
|
||||||
<Button
|
<SidebarGroupContent className="relative">
|
||||||
type="button"
|
<Button
|
||||||
variant="outline"
|
type="button"
|
||||||
className="hover:bg-background h-8 w-full justify-start pl-7 font-normal transition-[width] duration-200 ease-linear in-data-[state=collapsed]:w-8! in-data-[state=collapsed]:pl-4! in-data-[state=collapsed]:text-transparent"
|
variant="outline"
|
||||||
onClick={() => setOpen(true)}
|
className="hover:bg-background h-8 w-full justify-start pl-7 font-normal transition-[width] duration-200 ease-linear in-data-[state=collapsed]:w-8! in-data-[state=collapsed]:pl-4! in-data-[state=collapsed]:text-transparent"
|
||||||
>
|
onClick={() => setOpen(true)}
|
||||||
Поиск…
|
>
|
||||||
</Button>
|
Поиск…
|
||||||
<Search
|
</Button>
|
||||||
aria-hidden
|
<Search
|
||||||
className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 opacity-50 select-none"
|
aria-hidden
|
||||||
/>
|
className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 opacity-50 select-none"
|
||||||
<Kbd className="absolute top-1/2 right-2 -translate-y-1/2 in-data-[state=collapsed]:hidden">
|
/>
|
||||||
⌘K
|
<Kbd className="absolute top-1/2 right-2 -translate-y-1/2 in-data-[state=collapsed]:hidden">
|
||||||
</Kbd>
|
⌘K
|
||||||
</SidebarGroupContent>
|
</Kbd>
|
||||||
</SidebarGroup>
|
</SidebarGroupContent>
|
||||||
|
</SidebarGroup>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<Dialog
|
<Dialog
|
||||||
open={open}
|
open={open}
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import {
|
||||||
|
ChevronsUpDownIcon,
|
||||||
|
ExternalLinkIcon,
|
||||||
|
LogOutIcon,
|
||||||
|
SettingsIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
import { Avatar, AvatarFallback } from '@evobgp/ui/components/avatar'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuGroup,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuLabel,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
} from '@evobgp/ui/components/dropdown-menu'
|
||||||
|
import {
|
||||||
|
SidebarMenu,
|
||||||
|
SidebarMenuButton,
|
||||||
|
SidebarMenuItem,
|
||||||
|
useSidebar,
|
||||||
|
} from '@evobgp/ui/components/sidebar'
|
||||||
|
|
||||||
|
import { setToken as setApiToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
||||||
|
import {
|
||||||
|
authPortalUrl,
|
||||||
|
clearPortalToken,
|
||||||
|
getClaims,
|
||||||
|
isAuthEnabled,
|
||||||
|
redirectToPortalLogout,
|
||||||
|
resetPortalHandoff,
|
||||||
|
} from '@/lib/auth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sidebar footer account menu.
|
||||||
|
* Portal mode → shows JWT email + logout via auth-portal.
|
||||||
|
* Local mode → shows the API-key hint + clears the local token.
|
||||||
|
* @see https://reui.io/preview/base/app-shell-12
|
||||||
|
*/
|
||||||
|
function initials(source: string): string {
|
||||||
|
const base = source.trim()
|
||||||
|
if (!base) return '?'
|
||||||
|
const parts = base.split(/\s+/).filter(Boolean)
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
return `${parts[0]![0] ?? ''}${parts[1]![0] ?? ''}`.toUpperCase()
|
||||||
|
}
|
||||||
|
return base.slice(0, 2).toUpperCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NavUser() {
|
||||||
|
const { isMobile } = useSidebar()
|
||||||
|
const authOn = isAuthEnabled()
|
||||||
|
const claims = getClaims()
|
||||||
|
|
||||||
|
const name = claims?.name?.trim() || (authOn ? 'Пользователь' : 'Гость')
|
||||||
|
const email =
|
||||||
|
claims?.email?.trim() ||
|
||||||
|
(authOn ? '' : 'локальный API-токен')
|
||||||
|
const fallback = initials(name || email)
|
||||||
|
|
||||||
|
function handleSignOut() {
|
||||||
|
if (authOn) {
|
||||||
|
clearPortalToken()
|
||||||
|
resetPortalHandoff()
|
||||||
|
redirectToPortalLogout()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setApiToken(null)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.localStorage.removeItem(TOKEN_STORAGE_KEY)
|
||||||
|
window.location.assign('/settings?tab=connection&reason=token-required')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarMenu>
|
||||||
|
<SidebarMenuItem>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger
|
||||||
|
render={
|
||||||
|
<SidebarMenuButton
|
||||||
|
size="lg"
|
||||||
|
className="data-popup-open:bg-sidebar-accent data-popup-open:text-sidebar-accent-foreground"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<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-semibold">{name}</span>
|
||||||
|
<span className="truncate text-xs text-muted-foreground">
|
||||||
|
{email || '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronsUpDownIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent
|
||||||
|
className="w-(--anchor-width) min-w-56 rounded-lg"
|
||||||
|
side={isMobile ? 'bottom' : 'right'}
|
||||||
|
align="end"
|
||||||
|
sideOffset={4}
|
||||||
|
>
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<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
|
||||||
|
nativeButton={false}
|
||||||
|
render={<Link to="/settings" search={{ tab: 'connection' }} />}
|
||||||
|
>
|
||||||
|
<SettingsIcon aria-hidden />
|
||||||
|
Настройки UI
|
||||||
|
</DropdownMenuItem>
|
||||||
|
{authOn ? (
|
||||||
|
<DropdownMenuItem
|
||||||
|
nativeButton={false}
|
||||||
|
render={<a href={authPortalUrl()} />}
|
||||||
|
>
|
||||||
|
<ExternalLinkIcon aria-hidden />
|
||||||
|
Открыть Auth Portal
|
||||||
|
</DropdownMenuItem>
|
||||||
|
) : null}
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
<DropdownMenuGroup>
|
||||||
|
<DropdownMenuItem onClick={handleSignOut}>
|
||||||
|
<LogOutIcon aria-hidden />
|
||||||
|
{authOn ? 'Выйти' : 'Сбросить токен'}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuGroup>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</SidebarMenuItem>
|
||||||
|
</SidebarMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -181,7 +181,7 @@ export function SystemMonitorPopover() {
|
|||||||
</Badge>
|
</Badge>
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
|
|
||||||
<PopoverContent align="end" sideOffset={8} className="w-80 gap-0! space-y-0! p-0!">
|
<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">
|
<div className="border-border flex items-center justify-between border-b px-3 py-2.5">
|
||||||
<span className="text-foreground text-xs font-medium">Монитор EvoBGP</span>
|
<span className="text-foreground text-xs font-medium">Монитор EvoBGP</span>
|
||||||
<span className="text-muted-foreground text-[11px] tabular-nums">
|
<span className="text-muted-foreground text-[11px] tabular-nums">
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import { ColumnDef } from '@tanstack/react-table'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useNavigate } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import { CategoryBadge } from '@/components/category-badge'
|
||||||
|
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
|
import { DataGridCard, DataGridSection } from '@/components/data-grid-shell'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
|
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
||||||
|
import type { LookupMatch } from '@/types/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lookup matches grid — data-grid-filtering-2 pattern.
|
||||||
|
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||||
|
* @see https://reui.io/docs/components/base/badge
|
||||||
|
*/
|
||||||
|
export function LookupMatchesGrid({
|
||||||
|
items,
|
||||||
|
isLoading = false,
|
||||||
|
}: {
|
||||||
|
items: LookupMatch[]
|
||||||
|
isLoading?: boolean
|
||||||
|
}) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnDef<LookupMatch>[]>(
|
||||||
|
() => [
|
||||||
|
{
|
||||||
|
accessorKey: 'layer',
|
||||||
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Слой" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<Badge
|
||||||
|
variant={row.original.layer === 'entry' ? 'info-light' : 'primary-light'}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{row.original.layer}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
meta: { headerTitle: 'Слой' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'module_name',
|
||||||
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Модуль" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DataGridPrimaryCell
|
||||||
|
title={row.original.module_name}
|
||||||
|
subtitle={row.original.module_type}
|
||||||
|
accent="primary"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
meta: { headerTitle: 'Модуль' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: 'matched_value',
|
||||||
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Совпадение" />,
|
||||||
|
cell: ({ row }) => (
|
||||||
|
<DataGridPrimaryCell
|
||||||
|
title={row.original.matched_value}
|
||||||
|
subtitle={
|
||||||
|
row.original.resolved_ip
|
||||||
|
? `${row.original.match_kind} · via ${row.original.resolved_ip}`
|
||||||
|
: row.original.match_kind
|
||||||
|
}
|
||||||
|
accent="mono"
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
meta: { headerTitle: 'Совпадение' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'community',
|
||||||
|
accessorFn: (row) => row.community_title || row.community || '',
|
||||||
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Community" />,
|
||||||
|
cell: ({ row }) => {
|
||||||
|
const title = row.original.community_title?.trim()
|
||||||
|
const value = row.original.community?.trim()
|
||||||
|
if (!title && !value) {
|
||||||
|
return <span className="text-muted-foreground text-sm">—</span>
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<DataGridPrimaryCell
|
||||||
|
title={title || value || '—'}
|
||||||
|
subtitle={title && value && title !== value ? value : undefined}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
meta: { headerTitle: 'Community' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'source',
|
||||||
|
enableSorting: false,
|
||||||
|
header: 'Источник',
|
||||||
|
cell: ({ row }) =>
|
||||||
|
row.original.source ? (
|
||||||
|
<CategoryBadge>{row.original.source}</CategoryBadge>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground text-sm">—</span>
|
||||||
|
),
|
||||||
|
meta: { headerTitle: 'Источник' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
||||||
|
data: items,
|
||||||
|
columns,
|
||||||
|
getSearchText: (row) =>
|
||||||
|
`${row.layer} ${row.module_name} ${row.module_type} ${row.matched_value} ${row.community ?? ''} ${row.community_title ?? ''} ${row.source ?? ''}`,
|
||||||
|
getRowId: (row) =>
|
||||||
|
`${row.layer}|${row.module_id}|${row.match_kind}|${row.matched_value}|${row.entry_id ?? ''}|${row.source ?? ''}|${row.community_id ?? ''}`,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridCard
|
||||||
|
title="Совпадения"
|
||||||
|
description="Entries и snapshots · клик по строке открывает модуль"
|
||||||
|
>
|
||||||
|
<DataGridSection
|
||||||
|
table={table}
|
||||||
|
recordCount={filteredCount}
|
||||||
|
isLoading={isLoading}
|
||||||
|
emptyMessage="Нет совпадений"
|
||||||
|
searchValue={globalFilter}
|
||||||
|
onSearchChange={setGlobalFilter}
|
||||||
|
searchPlaceholder="Фильтр совпадений…"
|
||||||
|
onRowClick={(row) =>
|
||||||
|
void navigate({ to: '/modules/$moduleId', params: { moduleId: row.module_id } })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DataGridCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { useState, type FormEvent } from 'react'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { Field, FieldLabel } from '@evobgp/ui/components/field'
|
||||||
|
import {
|
||||||
|
InputGroup,
|
||||||
|
InputGroupAddon,
|
||||||
|
InputGroupInput,
|
||||||
|
} from '@evobgp/ui/components/input-group'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lookup search form — Frame + InputGroup (form-7 pattern).
|
||||||
|
* @see https://reui.io/preview/base/form-7
|
||||||
|
* @see https://reui.io/docs/components/base/frame
|
||||||
|
*/
|
||||||
|
export function LookupSearchForm({
|
||||||
|
initialQuery = '',
|
||||||
|
isPending = false,
|
||||||
|
onSubmit,
|
||||||
|
}: {
|
||||||
|
initialQuery?: string
|
||||||
|
isPending?: boolean
|
||||||
|
onSubmit: (q: string) => void
|
||||||
|
}) {
|
||||||
|
const [value, setValue] = useState(initialQuery)
|
||||||
|
|
||||||
|
function handleSubmit(e: FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
const q = value.trim()
|
||||||
|
if (!q) return
|
||||||
|
onSubmit(q)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame spacing="sm" className="w-full">
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle>Проверка списка</FrameTitle>
|
||||||
|
<FrameDescription>
|
||||||
|
IP или FQDN — поиск в entries и материализованных snapshots с community.
|
||||||
|
</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel>
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||||
|
<Field className="min-w-0 flex-1">
|
||||||
|
<FieldLabel htmlFor="lookup-q">IP или домен</FieldLabel>
|
||||||
|
<InputGroup>
|
||||||
|
<InputGroupAddon align="inline-start">
|
||||||
|
<Search aria-hidden />
|
||||||
|
</InputGroupAddon>
|
||||||
|
<InputGroupInput
|
||||||
|
id="lookup-q"
|
||||||
|
name="q"
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => setValue(e.target.value)}
|
||||||
|
placeholder="8.8.8.8 или example.com"
|
||||||
|
autoComplete="off"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</InputGroup>
|
||||||
|
</Field>
|
||||||
|
<Button type="submit" disabled={isPending || !value.trim()}>
|
||||||
|
Проверить
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { Globe, Layers, ListChecks, Radar } from 'lucide-react'
|
||||||
|
|
||||||
|
import { KpiStatGrid, type KpiStatItem } from '@/components/reui-kit'
|
||||||
|
import type { LookupResponse } from '@/types/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lookup summary KPI — stats-12 via KpiStatGrid.
|
||||||
|
* @see https://reui.io/preview/base/stats-12
|
||||||
|
*/
|
||||||
|
export function LookupSummaryKpi({ data }: { data: LookupResponse }) {
|
||||||
|
const entryCount = data.matches.filter((m) => m.layer === 'entry').length
|
||||||
|
const snapshotCount = data.matches.filter((m) => m.layer === 'snapshot').length
|
||||||
|
const resolvedCount = data.resolved_ips?.length ?? 0
|
||||||
|
|
||||||
|
const items: KpiStatItem[] = [
|
||||||
|
{
|
||||||
|
id: 'matched',
|
||||||
|
label: 'Результат',
|
||||||
|
value: data.matched ? 'Найдено' : 'Не найдено',
|
||||||
|
hint: data.normalized,
|
||||||
|
icon: <Radar aria-hidden />,
|
||||||
|
iconClassName: data.matched
|
||||||
|
? 'bg-success text-success-foreground [&_svg]:text-success-foreground'
|
||||||
|
: 'bg-muted text-muted-foreground [&_svg]:text-muted-foreground',
|
||||||
|
variant: data.matched ? 'default' : 'warning',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'entry',
|
||||||
|
label: 'Слой entry',
|
||||||
|
value: entryCount,
|
||||||
|
hint: 'сырые списки',
|
||||||
|
icon: <ListChecks aria-hidden />,
|
||||||
|
iconClassName: 'bg-info text-info-foreground [&_svg]:text-info-foreground',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'snapshot',
|
||||||
|
label: 'Слой snapshot',
|
||||||
|
value: snapshotCount,
|
||||||
|
hint: 'материализация',
|
||||||
|
icon: <Layers aria-hidden />,
|
||||||
|
iconClassName: 'bg-focus text-focus-foreground [&_svg]:text-focus-foreground',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
if (data.query_kind === 'domain') {
|
||||||
|
items.push({
|
||||||
|
id: 'resolved',
|
||||||
|
label: 'DNS IP',
|
||||||
|
value: resolvedCount,
|
||||||
|
hint: resolvedCount > 0 ? data.resolved_ips?.slice(0, 3).join(', ') : 'нет A/AAAA',
|
||||||
|
icon: <Globe aria-hidden />,
|
||||||
|
iconClassName: 'bg-primary text-primary-foreground [&_svg]:text-primary-foreground',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<KpiStatGrid
|
||||||
|
items={items}
|
||||||
|
aria-label={`Запрос: ${data.query_kind} · ${data.query}`}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
import { KpiSparklineCard, type KpiSparklineMetric } from '@/components/patterns/kpi-sparkline-card'
|
import { Clock, Gauge, Globe, Tags } from 'lucide-react'
|
||||||
import { kpiGridClassName } from '@/lib/ui-surface'
|
|
||||||
|
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
|
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
|
||||||
import {
|
import {
|
||||||
communityLabel,
|
communityLabel,
|
||||||
@@ -7,7 +9,7 @@ import {
|
|||||||
moduleDohProfileIds,
|
moduleDohProfileIds,
|
||||||
} from '@/lib/modules/helpers'
|
} from '@/lib/modules/helpers'
|
||||||
import { dohPolicyRu } from '@/lib/ui-labels'
|
import { dohPolicyRu } from '@/lib/ui-labels'
|
||||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
import { KpiStatGridSkeleton } from '@/components/skeletons'
|
||||||
import type { AsEntry, BgpCommunity, DohProfile, ModuleRow } from '@/types/api'
|
import type { AsEntry, BgpCommunity, DohProfile, ModuleRow } from '@/types/api'
|
||||||
|
|
||||||
interface ModuleKpiCardsProps {
|
interface ModuleKpiCardsProps {
|
||||||
@@ -18,12 +20,6 @@ interface ModuleKpiCardsProps {
|
|||||||
loading?: boolean
|
loading?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
function syntheticSparkline(seed: number): number[] {
|
|
||||||
return Array.from({ length: 9 }, (_, i) =>
|
|
||||||
Math.round(seed * (0.8 + (i / 9) * 0.2 + Math.sin(i) * 0.05)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModuleKpiCards({
|
export function ModuleKpiCards({
|
||||||
mod,
|
mod,
|
||||||
communities,
|
communities,
|
||||||
@@ -32,67 +28,65 @@ export function ModuleKpiCards({
|
|||||||
loading = false,
|
loading = false,
|
||||||
}: ModuleKpiCardsProps) {
|
}: ModuleKpiCardsProps) {
|
||||||
if (loading || !mod) {
|
if (loading || !mod) {
|
||||||
return <SectionCardsSkeleton count={4} />
|
return <KpiStatGridSkeleton count={4} />
|
||||||
}
|
}
|
||||||
|
|
||||||
const asPrefixTotal = asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
|
const asPrefixTotal = asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
|
||||||
const dohIds = moduleDohProfileIds(mod)
|
const dohIds = moduleDohProfileIds(mod)
|
||||||
|
|
||||||
const metrics: KpiSparklineMetric[] = [
|
const items: KpiStatItem[] = [
|
||||||
{
|
{
|
||||||
id: 'priority',
|
id: 'priority',
|
||||||
title: 'Приоритет',
|
icon: <Gauge aria-hidden />,
|
||||||
label: 'Порядок в ревизии',
|
iconClassName: 'text-info',
|
||||||
value: String(mod.priority ?? 0),
|
value: String(mod.priority ?? 0),
|
||||||
delta: mod.enabled !== false ? 'активен' : 'выключен',
|
label: 'Приоритет',
|
||||||
deltaVariant: mod.enabled !== false ? 'success-light' : 'outline',
|
footer: (
|
||||||
detail: mod.type,
|
<Badge variant={mod.enabled !== false ? 'success-light' : 'outline'} size="sm">
|
||||||
tone: 'info',
|
{mod.enabled !== false ? 'активен' : 'выключен'} · {mod.type}
|
||||||
sparkline: syntheticSparkline(mod.priority ?? 1),
|
</Badge>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'interval',
|
id: 'interval',
|
||||||
title: 'Интервал',
|
icon: <Clock aria-hidden />,
|
||||||
label: 'Обновление',
|
iconClassName: 'text-warning',
|
||||||
value: moduleIntervalLabel(mod),
|
value: moduleIntervalLabel(mod),
|
||||||
delta: formatDateTime(mod.last_refreshed_at) || 'никогда',
|
label: 'Интервал обновления',
|
||||||
deltaVariant: 'primary-light',
|
footer: (
|
||||||
detail: 'last refresh',
|
<Badge variant="primary-light" size="sm">
|
||||||
tone: 'warning',
|
{formatDateTime(mod.last_refreshed_at) || 'никогда'}
|
||||||
sparkline: syntheticSparkline(12),
|
</Badge>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'prefixes',
|
id: 'prefixes',
|
||||||
title: 'Префиксы',
|
icon: <Tags aria-hidden />,
|
||||||
label: mod.type === 'AS_PREFIXES' ? 'AS entries' : 'Записи',
|
iconClassName: 'text-success',
|
||||||
value: mod.type === 'AS_PREFIXES' ? String(asPrefixTotal) : String(asEntries.length),
|
value: mod.type === 'AS_PREFIXES' ? String(asPrefixTotal) : String(asEntries.length),
|
||||||
delta: `${asEntries.length} AS`,
|
label: mod.type === 'AS_PREFIXES' ? 'Префиксы AS' : 'Записи модуля',
|
||||||
deltaVariant: 'success-light',
|
footer: (
|
||||||
detail: 'в модуле',
|
<Badge variant="success-light" size="sm">
|
||||||
tone: 'success',
|
{asEntries.length} AS · в модуле
|
||||||
sparkline: syntheticSparkline(asPrefixTotal || asEntries.length || 1),
|
</Badge>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'policy',
|
id: 'policy',
|
||||||
title: 'DoH / BGP',
|
icon: <Globe aria-hidden />,
|
||||||
label: communityLabel(mod.default_community_id, communities),
|
iconClassName: 'text-primary',
|
||||||
value: dohIds.length > 0 ? String(dohIds.length) : '—',
|
value: dohIds.length > 0 ? String(dohIds.length) : '—',
|
||||||
delta: dohPolicyRu(mod.doh_resolver_policy),
|
label: communityLabel(mod.default_community_id, communities),
|
||||||
deltaVariant: 'info-light',
|
footer: (
|
||||||
detail:
|
<Badge variant="info-light" size="sm">
|
||||||
dohIds.length > 0
|
{dohPolicyRu(mod.doh_resolver_policy)}
|
||||||
? dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join(', ')
|
{dohIds.length > 0
|
||||||
: 'без DoH',
|
? ` · ${dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join(', ')}`
|
||||||
tone: 'info',
|
: ' · без DoH'}
|
||||||
sparkline: syntheticSparkline(dohIds.length || 2),
|
</Badge>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return <KpiStatGrid items={items} aria-label="KPI модуля" />
|
||||||
<section aria-label="KPI модуля" className={kpiGridClassName}>
|
|
||||||
{metrics.map((metric) => (
|
|
||||||
<KpiSparklineCard key={metric.id} metric={metric} />
|
|
||||||
))}
|
|
||||||
</section>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { AlertTriangle, Network, ServerCog, Share2 } from 'lucide-react'
|
||||||
|
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { KpiStatGrid, type KpiStatCardData } from '@/components/reui-kit'
|
||||||
|
import { aggregateNetworkMetrics } from '@/queries/overview'
|
||||||
|
import type { BirdStatus, PeerRow, SpeakerRow } from '@/types/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Network page KPI strip.
|
||||||
|
* @see https://reui.io/preview/base/stats-12
|
||||||
|
*/
|
||||||
|
export function NetworkKpi({
|
||||||
|
peers,
|
||||||
|
speakers,
|
||||||
|
bird,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
peers: PeerRow[]
|
||||||
|
speakers: SpeakerRow[]
|
||||||
|
bird?: BirdStatus
|
||||||
|
loading?: boolean
|
||||||
|
}) {
|
||||||
|
const net = aggregateNetworkMetrics(peers, speakers)
|
||||||
|
const birdHealthy = bird?.healthy
|
||||||
|
const birdSessions =
|
||||||
|
bird != null ? `${bird.bgp_established} / ${bird.bgp_sessions_total}` : '—'
|
||||||
|
|
||||||
|
const cards: KpiStatCardData[] = [
|
||||||
|
{
|
||||||
|
id: 'peers-established',
|
||||||
|
icon: <Share2 aria-hidden />,
|
||||||
|
iconClassName: 'text-success',
|
||||||
|
label: 'Пиры Established',
|
||||||
|
value: loading ? '—' : `${net.peersEstablished}/${net.peersEnabled}`,
|
||||||
|
footer: (
|
||||||
|
<Badge variant="success-light" size="sm">
|
||||||
|
{loading ? '…' : `${net.peersTotal} в каталоге`}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
to: '/network',
|
||||||
|
search: { tab: 'peers' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'speakers-online',
|
||||||
|
icon: <ServerCog aria-hidden />,
|
||||||
|
iconClassName: 'text-info',
|
||||||
|
label: 'Спикеры online',
|
||||||
|
value: loading ? '—' : `${net.speakersOnline}/${net.speakersTotal}`,
|
||||||
|
footer: (
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
net.speakersOnline === net.speakersTotal && net.speakersTotal > 0
|
||||||
|
? 'success-light'
|
||||||
|
: 'warning-light'
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{loading ? '…' : 'live'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
to: '/network',
|
||||||
|
search: { tab: 'speakers' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mismatches',
|
||||||
|
icon: <AlertTriangle aria-hidden />,
|
||||||
|
iconClassName: net.peersMismatch > 0 ? 'text-warning' : 'text-muted-foreground',
|
||||||
|
label: 'Расхождения',
|
||||||
|
value: loading ? '—' : String(net.peersMismatch),
|
||||||
|
variant: net.peersMismatch > 0 ? 'warning' : 'default',
|
||||||
|
footer: (
|
||||||
|
<Badge variant={net.peersMismatch > 0 ? 'warning-light' : 'outline'} size="sm">
|
||||||
|
{loading ? '…' : net.peersMismatch > 0 ? 'проверить' : 'в норме'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
to: '/network',
|
||||||
|
search: { tab: 'peers' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bird',
|
||||||
|
icon: <Network aria-hidden />,
|
||||||
|
iconClassName:
|
||||||
|
birdHealthy === false ? 'text-destructive' : 'text-primary',
|
||||||
|
label: 'BIRD',
|
||||||
|
value: loading ? '—' : birdSessions,
|
||||||
|
variant: birdHealthy === false ? 'destructive' : 'default',
|
||||||
|
footer: (
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
birdHealthy === true
|
||||||
|
? 'success-light'
|
||||||
|
: birdHealthy === false
|
||||||
|
? 'destructive-light'
|
||||||
|
: 'outline'
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{loading
|
||||||
|
? '…'
|
||||||
|
: birdHealthy === true
|
||||||
|
? 'в норме'
|
||||||
|
: birdHealthy === false
|
||||||
|
? 'проблема'
|
||||||
|
: 'н/д'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section aria-label="Ключевые метрики сети">
|
||||||
|
<KpiStatGrid cards={cards} isLoading={loading} skeletonCount={4} />
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,17 +1,76 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { Plus } from 'lucide-react'
|
import { Plus, SearchIcon, ActivityIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
|
||||||
|
|
||||||
import { DataGridCard } from '@/components/data-grid-shell'
|
|
||||||
import { NetworkPeersGrid } from '@/components/network/network-peers-grid'
|
|
||||||
import { PeerFormDialog } from '@/components/network/peer-form-dialog'
|
import { PeerFormDialog } from '@/components/network/peer-form-dialog'
|
||||||
import { QueryState } from '@/components/query-state'
|
import {
|
||||||
import { TableSkeleton } from '@/components/skeletons'
|
getPeerFilterFieldValue,
|
||||||
|
peerColumns,
|
||||||
|
peerTabFilter,
|
||||||
|
} from '@/components/network/network-peers-grid'
|
||||||
|
import {
|
||||||
|
createFilter,
|
||||||
|
type Filter,
|
||||||
|
type FilterFieldConfig,
|
||||||
|
} from '@/components/reui/filters'
|
||||||
|
import { ResourcePage, renderSingleSelectedLabel } from '@/components/reui-kit'
|
||||||
import type { PeerRow, SpeakerRow } from '@/types/api'
|
import type { PeerRow, SpeakerRow } from '@/types/api'
|
||||||
|
|
||||||
type PeerTab = 'all' | 'established' | 'pending' | 'disabled'
|
/**
|
||||||
|
* BGP peers list — ResourcePage (Frame + tabs + Filters + DataGrid).
|
||||||
|
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PEER_TABS = [
|
||||||
|
{ id: 'all', label: 'Все' },
|
||||||
|
{ id: 'established', label: 'Established' },
|
||||||
|
{ id: 'pending', label: 'Ожидание' },
|
||||||
|
{ id: 'disabled', label: 'Выключены' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const SESSION_STATE_OPTIONS = [
|
||||||
|
{ value: 'Established', label: 'Established' },
|
||||||
|
{ value: 'Idle', label: 'Idle' },
|
||||||
|
{ value: 'Active', label: 'Active' },
|
||||||
|
{ value: 'Connect', label: 'Connect' },
|
||||||
|
{ value: 'OpenSent', label: 'OpenSent' },
|
||||||
|
{ value: 'OpenConfirm', label: 'OpenConfirm' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function createDefaultPeerFilters(): Filter[] {
|
||||||
|
return [createFilter('name', 'contains', [''])]
|
||||||
|
}
|
||||||
|
|
||||||
|
const peerFilterFields: FilterFieldConfig[] = [
|
||||||
|
{
|
||||||
|
key: 'name',
|
||||||
|
label: 'Имя',
|
||||||
|
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||||
|
type: 'text',
|
||||||
|
className: 'w-48',
|
||||||
|
placeholder: 'Поиск по имени…',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'neighbor',
|
||||||
|
label: 'Сосед',
|
||||||
|
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||||
|
type: 'text',
|
||||||
|
className: 'w-48',
|
||||||
|
placeholder: 'Адрес соседа…',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'session_state',
|
||||||
|
label: 'Состояние',
|
||||||
|
icon: <ActivityIcon className="size-3.5" aria-hidden />,
|
||||||
|
type: 'select',
|
||||||
|
searchable: false,
|
||||||
|
className: 'w-[160px]',
|
||||||
|
options: SESSION_STATE_OPTIONS,
|
||||||
|
customValueRenderer: (values) =>
|
||||||
|
renderSingleSelectedLabel(values, SESSION_STATE_OPTIONS),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
interface NetworkPeersCardProps {
|
interface NetworkPeersCardProps {
|
||||||
items: PeerRow[]
|
items: PeerRow[]
|
||||||
@@ -22,24 +81,6 @@ interface NetworkPeersCardProps {
|
|||||||
onRetry: () => void
|
onRetry: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterPeers(items: PeerRow[], tab: PeerTab): PeerRow[] {
|
|
||||||
if (tab === 'all') return items
|
|
||||||
if (tab === 'disabled') return items.filter((p) => p.enabled === false)
|
|
||||||
const enabled = items.filter((p) => p.enabled !== false)
|
|
||||||
if (tab === 'established') return enabled.filter((p) => p.session_state === 'Established')
|
|
||||||
return enabled.filter((p) => p.session_state !== 'Established')
|
|
||||||
}
|
|
||||||
|
|
||||||
function tabCounts(items: PeerRow[]) {
|
|
||||||
const enabled = items.filter((p) => p.enabled !== false)
|
|
||||||
return {
|
|
||||||
all: items.length,
|
|
||||||
established: enabled.filter((p) => p.session_state === 'Established').length,
|
|
||||||
pending: enabled.filter((p) => p.session_state !== 'Established').length,
|
|
||||||
disabled: items.length - enabled.length,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function NetworkPeersCard({
|
export function NetworkPeersCard({
|
||||||
items,
|
items,
|
||||||
speakers,
|
speakers,
|
||||||
@@ -49,48 +90,44 @@ export function NetworkPeersCard({
|
|||||||
onRetry,
|
onRetry,
|
||||||
}: NetworkPeersCardProps) {
|
}: NetworkPeersCardProps) {
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
const [tab, setTab] = useState<PeerTab>('all')
|
const [filters, setFilters] = useState<Filter[]>(createDefaultPeerFilters)
|
||||||
const counts = useMemo(() => tabCounts(items), [items])
|
|
||||||
const filtered = useMemo(() => filterPeers(items, tab), [items, tab])
|
const addButton = useMemo(
|
||||||
|
() => (
|
||||||
|
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
|
||||||
|
<Plus />
|
||||||
|
Добавить пира
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DataGridCard
|
<ResourcePage
|
||||||
title="Пиры"
|
title="Пиры"
|
||||||
description="BGP-соседи и привязка к спикерам"
|
description="BGP-соседи и привязка к спикерам"
|
||||||
actions={
|
tabs={PEER_TABS}
|
||||||
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
|
tabFilter={peerTabFilter}
|
||||||
<Plus />
|
filterFields={peerFilterFields}
|
||||||
Добавить пира
|
filters={filters}
|
||||||
</Button>
|
onFiltersChange={setFilters}
|
||||||
}
|
onClearFilters={() => setFilters(createDefaultPeerFilters())}
|
||||||
>
|
getFilterFieldValue={getPeerFilterFieldValue}
|
||||||
<div className="border-b px-5 py-3">
|
columns={peerColumns}
|
||||||
<Tabs value={tab} onValueChange={(v) => setTab(v as PeerTab)}>
|
data={items}
|
||||||
<TabsList>
|
getRowId={(row) => row.id}
|
||||||
<TabsTrigger value="all">Все ({counts.all})</TabsTrigger>
|
isLoading={isLoading}
|
||||||
<TabsTrigger value="established">Established ({counts.established})</TabsTrigger>
|
isError={isError}
|
||||||
<TabsTrigger value="pending">Ожидание ({counts.pending})</TabsTrigger>
|
error={error instanceof Error ? error : null}
|
||||||
<TabsTrigger value="disabled">Выключены ({counts.disabled})</TabsTrigger>
|
onRetry={onRetry}
|
||||||
</TabsList>
|
primaryAction={addButton}
|
||||||
</Tabs>
|
emptyState={{
|
||||||
</div>
|
title: 'Нет пиров',
|
||||||
<QueryState
|
description: 'Добавьте первого BGP-соседа.',
|
||||||
data={filtered}
|
action: addButton,
|
||||||
isLoading={isLoading}
|
}}
|
||||||
isError={isError}
|
/>
|
||||||
error={error}
|
|
||||||
empty={filtered.length === 0}
|
|
||||||
emptyTitle="Нет пиров в выборке"
|
|
||||||
emptyDescription="Измените фильтр или добавьте BGP-соседа."
|
|
||||||
skeleton={<TableSkeleton rows={6} cols={4} />}
|
|
||||||
onRetry={onRetry}
|
|
||||||
>
|
|
||||||
{(data) => (
|
|
||||||
<NetworkPeersGrid items={data} isLoading={isLoading && data.length > 0} />
|
|
||||||
)}
|
|
||||||
</QueryState>
|
|
||||||
</DataGridCard>
|
|
||||||
|
|
||||||
<PeerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} speakers={speakers} />
|
<PeerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} speakers={speakers} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,90 +1,77 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
import { useMemo } from 'react'
|
|
||||||
|
|
||||||
import { CategoryBadge } from '@/components/category-badge'
|
import { CategoryBadge } from '@/components/category-badge'
|
||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
|
||||||
import { bgpSessionStateRu } from '@/lib/ui-labels'
|
import { bgpSessionStateRu } from '@/lib/ui-labels'
|
||||||
import type { PeerRow } from '@/types/api'
|
import type { PeerRow } from '@/types/api'
|
||||||
|
|
||||||
export function NetworkPeersGrid({
|
export const peerColumns: ColumnDef<PeerRow, unknown>[] = [
|
||||||
items,
|
{
|
||||||
isLoading = false,
|
id: 'name',
|
||||||
}: {
|
accessorFn: (row) => row.name ?? row.neighbor,
|
||||||
items: PeerRow[]
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
||||||
isLoading?: boolean
|
cell: ({ row }) => (
|
||||||
}) {
|
<DataGridPrimaryCell
|
||||||
const columns = useMemo<ColumnDef<PeerRow>[]>(
|
title={row.original.name ?? row.original.neighbor}
|
||||||
() => [
|
subtitle={row.original.name ? row.original.neighbor : undefined}
|
||||||
{
|
accent="primary"
|
||||||
id: 'name',
|
/>
|
||||||
accessorFn: (row) => row.name ?? row.neighbor,
|
),
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Имя" />,
|
meta: { headerTitle: 'Имя' },
|
||||||
cell: ({ row }) => (
|
},
|
||||||
<DataGridPrimaryCell
|
{
|
||||||
title={row.original.name ?? row.original.neighbor}
|
accessorKey: 'neighbor',
|
||||||
subtitle={row.original.name ? row.original.neighbor : undefined}
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Адрес соседа" />,
|
||||||
accent="primary"
|
cell: ({ row }) => (
|
||||||
/>
|
<DataGridPrimaryCell title={row.original.neighbor} accent="mono" />
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Имя' },
|
meta: { headerTitle: 'Адрес соседа' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'neighbor',
|
accessorKey: 'remote_asn',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Адрес соседа" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<DataGridPrimaryCell title={row.original.neighbor} accent="mono" />
|
<span className="font-mono text-xs">{row.original.remote_asn ?? '—'}</span>
|
||||||
),
|
),
|
||||||
meta: { headerTitle: 'Адрес соседа' },
|
meta: { headerTitle: 'ASN' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'remote_asn',
|
accessorKey: 'session_state',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="ASN" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
<span className="font-mono text-xs">{row.original.remote_asn ?? '—'}</span>
|
<div className="flex items-center gap-1">
|
||||||
),
|
<StatusBadge
|
||||||
meta: { headerTitle: 'ASN' },
|
status={row.original.session_state ?? '—'}
|
||||||
},
|
label={bgpSessionStateRu(row.original.session_state)}
|
||||||
{
|
/>
|
||||||
accessorKey: 'session_state',
|
{row.original.session_mismatch ? (
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Состояние" />,
|
<CategoryBadge tone="warning">расхождение</CategoryBadge>
|
||||||
cell: ({ row }) => (
|
) : null}
|
||||||
<div className="flex items-center gap-1">
|
</div>
|
||||||
<StatusBadge
|
),
|
||||||
status={row.original.session_state ?? '—'}
|
meta: { headerTitle: 'Состояние' },
|
||||||
label={bgpSessionStateRu(row.original.session_state)}
|
},
|
||||||
/>
|
]
|
||||||
{row.original.session_mismatch ? (
|
|
||||||
<CategoryBadge tone="warning">расхождение</CategoryBadge>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
meta: { headerTitle: 'Состояние' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
export function getPeerFilterFieldValue(item: PeerRow, field: string): unknown {
|
||||||
data: items,
|
switch (field) {
|
||||||
columns,
|
case 'name':
|
||||||
getSearchText: (row) =>
|
return item.name ?? item.neighbor
|
||||||
`${row.name ?? ''} ${row.neighbor} ${row.remote_asn ?? ''} ${row.session_state ?? ''}`,
|
case 'neighbor':
|
||||||
getRowId: (row) => row.id,
|
return item.neighbor
|
||||||
})
|
case 'session_state':
|
||||||
|
return item.session_state
|
||||||
|
default:
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
export function peerTabFilter(item: PeerRow, tabId: string): boolean {
|
||||||
<DataGridSection
|
if (tabId === 'disabled') return item.enabled === false
|
||||||
table={table}
|
const enabled = item.enabled !== false
|
||||||
recordCount={filteredCount}
|
if (tabId === 'established') return enabled && item.session_state === 'Established'
|
||||||
isLoading={isLoading}
|
if (tabId === 'pending') return enabled && item.session_state !== 'Established'
|
||||||
emptyMessage="Нет пиров"
|
return true
|
||||||
searchValue={globalFilter}
|
|
||||||
onSearchChange={setGlobalFilter}
|
|
||||||
searchPlaceholder="Поиск пиров…"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,65 @@
|
|||||||
import { useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { Plus } from 'lucide-react'
|
import { Plus, SearchIcon, TagIcon } from 'lucide-react'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
|
||||||
import { DataGridCard } from '@/components/data-grid-shell'
|
|
||||||
import { NetworkSpeakersGrid } from '@/components/network/network-speakers-grid'
|
|
||||||
import { SpeakerFormDialog } from '@/components/network/speaker-form-dialog'
|
import { SpeakerFormDialog } from '@/components/network/speaker-form-dialog'
|
||||||
import { QueryState } from '@/components/query-state'
|
import {
|
||||||
import { TableSkeleton } from '@/components/skeletons'
|
getSpeakerFilterFieldValue,
|
||||||
|
speakerColumns,
|
||||||
|
speakerTabFilter,
|
||||||
|
} from '@/components/network/network-speakers-grid'
|
||||||
|
import {
|
||||||
|
createFilter,
|
||||||
|
type Filter,
|
||||||
|
type FilterFieldConfig,
|
||||||
|
} from '@/components/reui/filters'
|
||||||
|
import { ResourcePage, renderSingleSelectedLabel } from '@/components/reui-kit'
|
||||||
import type { SpeakerRow } from '@/types/api'
|
import type { SpeakerRow } from '@/types/api'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BGP speakers list — ResourcePage (Frame + tabs + Filters + DataGrid).
|
||||||
|
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SPEAKER_TABS = [
|
||||||
|
{ id: 'all', label: 'Все' },
|
||||||
|
{ id: 'online', label: 'Online' },
|
||||||
|
{ id: 'offline', label: 'Offline' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const ROLE_OPTIONS = [
|
||||||
|
{ value: 'primary', label: 'primary' },
|
||||||
|
{ value: 'secondary', label: 'secondary' },
|
||||||
|
{ value: 'speaker', label: 'speaker' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function createDefaultSpeakerFilters(): Filter[] {
|
||||||
|
return [createFilter('endpoint', 'contains', [''])]
|
||||||
|
}
|
||||||
|
|
||||||
|
const speakerFilterFields: FilterFieldConfig[] = [
|
||||||
|
{
|
||||||
|
key: 'endpoint',
|
||||||
|
label: 'Конечная точка',
|
||||||
|
icon: <SearchIcon className="size-3.5" aria-hidden />,
|
||||||
|
type: 'text',
|
||||||
|
className: 'w-52',
|
||||||
|
placeholder: 'endpoint…',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'role',
|
||||||
|
label: 'Роль',
|
||||||
|
icon: <TagIcon className="size-3.5" aria-hidden />,
|
||||||
|
type: 'select',
|
||||||
|
searchable: false,
|
||||||
|
className: 'w-[140px]',
|
||||||
|
options: ROLE_OPTIONS,
|
||||||
|
customValueRenderer: (values) =>
|
||||||
|
renderSingleSelectedLabel(values, ROLE_OPTIONS),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
interface NetworkSpeakersCardProps {
|
interface NetworkSpeakersCardProps {
|
||||||
items: SpeakerRow[]
|
items: SpeakerRow[]
|
||||||
isLoading: boolean
|
isLoading: boolean
|
||||||
@@ -26,35 +76,44 @@ export function NetworkSpeakersCard({
|
|||||||
onRetry,
|
onRetry,
|
||||||
}: NetworkSpeakersCardProps) {
|
}: NetworkSpeakersCardProps) {
|
||||||
const [dialogOpen, setDialogOpen] = useState(false)
|
const [dialogOpen, setDialogOpen] = useState(false)
|
||||||
|
const [filters, setFilters] = useState<Filter[]>(createDefaultSpeakerFilters)
|
||||||
|
|
||||||
|
const addButton = useMemo(
|
||||||
|
() => (
|
||||||
|
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
|
||||||
|
<Plus />
|
||||||
|
Добавить спикера
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DataGridCard
|
<ResourcePage
|
||||||
title="Спикеры"
|
title="Спикеры"
|
||||||
description="BIRD-агенты на нодах tenant"
|
description="BIRD-агенты на нодах tenant"
|
||||||
actions={
|
tabs={SPEAKER_TABS}
|
||||||
<Button size="sm" type="button" onClick={() => setDialogOpen(true)}>
|
tabFilter={speakerTabFilter}
|
||||||
<Plus />
|
filterFields={speakerFilterFields}
|
||||||
Добавить спикера
|
filters={filters}
|
||||||
</Button>
|
onFiltersChange={setFilters}
|
||||||
}
|
onClearFilters={() => setFilters(createDefaultSpeakerFilters())}
|
||||||
>
|
getFilterFieldValue={getSpeakerFilterFieldValue}
|
||||||
<QueryState
|
columns={speakerColumns}
|
||||||
data={items}
|
data={items}
|
||||||
isLoading={isLoading}
|
getRowId={(row) => row.id}
|
||||||
isError={isError}
|
isLoading={isLoading}
|
||||||
error={error}
|
isError={isError}
|
||||||
empty={items.length === 0}
|
error={error instanceof Error ? error : null}
|
||||||
emptyTitle="Нет спикеров"
|
onRetry={onRetry}
|
||||||
emptyDescription="Добавьте первого BIRD-агента на ноде."
|
primaryAction={addButton}
|
||||||
skeleton={<TableSkeleton rows={6} cols={4} />}
|
emptyState={{
|
||||||
onRetry={onRetry}
|
title: 'Нет спикеров',
|
||||||
>
|
description: 'Добавьте первого BIRD-агента на ноде.',
|
||||||
{(data) => (
|
action: addButton,
|
||||||
<NetworkSpeakersGrid items={data} isLoading={isLoading && data.length > 0} />
|
}}
|
||||||
)}
|
/>
|
||||||
</QueryState>
|
|
||||||
</DataGridCard>
|
|
||||||
|
|
||||||
<SpeakerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
<SpeakerFormDialog open={dialogOpen} onOpenChange={setDialogOpen} />
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,87 +1,78 @@
|
|||||||
import { ColumnDef } from '@tanstack/react-table'
|
import type { ColumnDef } from '@tanstack/react-table'
|
||||||
import { useMemo } from 'react'
|
|
||||||
|
|
||||||
import { CategoryBadge } from '@/components/category-badge'
|
import { CategoryBadge } from '@/components/category-badge'
|
||||||
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
import { DataGridPrimaryCell } from '@/components/data-grid-cell'
|
||||||
import { DataGridSection } from '@/components/data-grid-shell'
|
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { Badge } from '@/components/reui/badge'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
import { DataGridColumnHeader } from '@/components/reui/data-grid/data-grid-column-header'
|
||||||
import { useClientDataGrid } from '@/hooks/use-client-data-grid'
|
|
||||||
import { speakerOnlineLabel } from '@/lib/ui-labels'
|
import { speakerOnlineLabel } from '@/lib/ui-labels'
|
||||||
import type { SpeakerRow } from '@/types/api'
|
import type { SpeakerRow } from '@/types/api'
|
||||||
|
|
||||||
export function NetworkSpeakersGrid({
|
export const speakerColumns: ColumnDef<SpeakerRow, unknown>[] = [
|
||||||
items,
|
{
|
||||||
isLoading = false,
|
accessorKey: 'endpoint',
|
||||||
}: {
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Конечная точка" />,
|
||||||
items: SpeakerRow[]
|
cell: ({ row }) => (
|
||||||
isLoading?: boolean
|
<DataGridPrimaryCell title={row.original.endpoint} accent="mono" />
|
||||||
}) {
|
),
|
||||||
const columns = useMemo<ColumnDef<SpeakerRow>[]>(
|
meta: { headerTitle: 'Конечная точка' },
|
||||||
() => [
|
},
|
||||||
{
|
{
|
||||||
accessorKey: 'endpoint',
|
accessorKey: 'role',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Конечная точка" />,
|
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>,
|
||||||
<DataGridPrimaryCell title={row.original.endpoint} accent="mono" />
|
meta: { headerTitle: 'Роль' },
|
||||||
),
|
},
|
||||||
meta: { headerTitle: 'Конечная точка' },
|
{
|
||||||
},
|
id: 'agent',
|
||||||
{
|
enableSorting: false,
|
||||||
accessorKey: 'role',
|
header: 'Агент',
|
||||||
header: ({ column }) => <DataGridColumnHeader column={column} title="Роль" />,
|
cell: ({ row }) => {
|
||||||
cell: ({ row }) => <CategoryBadge>{row.original.role}</CategoryBadge>,
|
const live = row.original.live
|
||||||
meta: { headerTitle: 'Роль' },
|
if (live?.agent_ok === true) {
|
||||||
},
|
return <StatusBadge status="ok" label={speakerOnlineLabel(true)} />
|
||||||
{
|
}
|
||||||
id: 'agent',
|
if (live?.agent_ok === false) {
|
||||||
enableSorting: false,
|
return <StatusBadge status="error" label={speakerOnlineLabel(false)} />
|
||||||
header: 'Агент',
|
}
|
||||||
cell: ({ row }) => {
|
return (
|
||||||
const live = row.original.live
|
<Badge variant="outline" size="sm" radius="full">
|
||||||
if (live?.agent_ok === true) return <StatusBadge status="ok" label={speakerOnlineLabel(true)} />
|
—
|
||||||
if (live?.agent_ok === false) return <StatusBadge status="error" label={speakerOnlineLabel(false)} />
|
</Badge>
|
||||||
return <Badge variant="outline" size="sm" radius="full">—</Badge>
|
)
|
||||||
},
|
},
|
||||||
meta: { headerTitle: 'Агент' },
|
meta: { headerTitle: 'Агент' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'bgp',
|
id: 'bgp',
|
||||||
enableSorting: false,
|
enableSorting: false,
|
||||||
header: 'BGP',
|
header: 'BGP',
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const live = row.original.live
|
const live = row.original.live
|
||||||
if (!live) return '—'
|
if (!live) return '—'
|
||||||
return (
|
return (
|
||||||
<span className="text-xs">
|
<span className="text-xs">
|
||||||
{live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0}
|
{live.bgp_established ?? 0} / {live.bgp_sessions_total ?? 0}
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
meta: { headerTitle: 'BGP' },
|
meta: { headerTitle: 'BGP' },
|
||||||
},
|
},
|
||||||
],
|
]
|
||||||
[],
|
|
||||||
)
|
|
||||||
|
|
||||||
const { table, globalFilter, setGlobalFilter, filteredCount } = useClientDataGrid({
|
export function getSpeakerFilterFieldValue(item: SpeakerRow, field: string): unknown {
|
||||||
data: items,
|
switch (field) {
|
||||||
columns,
|
case 'endpoint':
|
||||||
getSearchText: (row) =>
|
return item.endpoint
|
||||||
`${row.endpoint} ${row.role} ${row.agent_domain ?? ''} ${row.node_ipv4 ?? ''}`,
|
case 'role':
|
||||||
getRowId: (row) => row.id,
|
return item.role
|
||||||
})
|
default:
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
export function speakerTabFilter(item: SpeakerRow, tabId: string): boolean {
|
||||||
<DataGridSection
|
if (tabId === 'online') return item.live?.agent_ok === true
|
||||||
table={table}
|
if (tabId === 'offline') return item.live?.agent_ok !== true
|
||||||
recordCount={filteredCount}
|
return true
|
||||||
isLoading={isLoading}
|
|
||||||
emptyMessage="Нет спикеров"
|
|
||||||
searchValue={globalFilter}
|
|
||||||
onSearchChange={setGlobalFilter}
|
|
||||||
searchPlaceholder="Поиск спикеров…"
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,9 +52,9 @@ export function OperationsJobsCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGridCard title="Задачи" description="Фильтр по статусу · data-grid-filtering pattern">
|
<DataGridCard title="Задачи" description="Фильтр по статусу · data-grid-filtering pattern">
|
||||||
<div className="border-b px-5 py-3">
|
<div className="px-5 pt-3">
|
||||||
<Tabs value={tab} onValueChange={(v) => setTab(v as JobTab)}>
|
<Tabs value={tab} onValueChange={(v) => setTab(v as JobTab)} className="w-full">
|
||||||
<TabsList>
|
<TabsList variant="line" className="w-full justify-start gap-6">
|
||||||
<TabsTrigger value="all">Все ({counts.all})</TabsTrigger>
|
<TabsTrigger value="all">Все ({counts.all})</TabsTrigger>
|
||||||
<TabsTrigger value="active">Активные ({counts.active})</TabsTrigger>
|
<TabsTrigger value="active">Активные ({counts.active})</TabsTrigger>
|
||||||
<TabsTrigger value="succeeded">Успешные ({counts.succeeded})</TabsTrigger>
|
<TabsTrigger value="succeeded">Успешные ({counts.succeeded})</TabsTrigger>
|
||||||
|
|||||||
@@ -1,21 +1,24 @@
|
|||||||
import type { ReactNode } from 'react'
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Frame,
|
||||||
CardAction,
|
FrameDescription,
|
||||||
CardContent,
|
FrameFooter,
|
||||||
CardDescription,
|
FrameHeader,
|
||||||
CardFooter,
|
FramePanel,
|
||||||
CardHeader,
|
FrameTitle,
|
||||||
CardTitle,
|
} from '@/components/reui/frame'
|
||||||
} from '@evobgp/ui/components/card'
|
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
|
||||||
/** REUI c-data-grid-19: card shell with default spacing and flush grid body. */
|
/**
|
||||||
export const panelCardClassName = 'gap-0 has-data-[slot=card-footer]:pb-0'
|
* Frame shell class constants (legacy Card names kept for DataGridShell / toolbar).
|
||||||
|
* @see https://reui.io/docs/components/base/frame
|
||||||
|
*/
|
||||||
|
export const panelCardClassName = 'w-full gap-0 p-0'
|
||||||
export const panelCardHeaderClassName = 'border-b'
|
export const panelCardHeaderClassName = 'border-b'
|
||||||
export const panelCardContentFlushClassName = 'px-0'
|
/** Flush body inside FramePanel (toolbar / data-grid). */
|
||||||
/** Horizontal inset for toolbar / pagination rows (matches edgeCell px-5). */
|
export const panelCardContentFlushClassName = 'p-0'
|
||||||
|
/** Horizontal inset for toolbar / pagination rows. */
|
||||||
export const panelCardInsetClassName = 'px-5 py-3'
|
export const panelCardInsetClassName = 'px-5 py-3'
|
||||||
export const panelCardFooterClassName = 'border-t bg-transparent'
|
export const panelCardFooterClassName = 'border-t bg-transparent'
|
||||||
|
|
||||||
@@ -45,22 +48,45 @@ export function PanelCard({
|
|||||||
size = 'default',
|
size = 'default',
|
||||||
}: PanelCardProps) {
|
}: PanelCardProps) {
|
||||||
const hasHeader = Boolean(title || description || actions)
|
const hasHeader = Boolean(title || description || actions)
|
||||||
|
const spacing = size === 'sm' ? 'sm' : 'default'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card size={size} className={cn(panelCardClassName, className)}>
|
<Frame
|
||||||
{hasHeader ? (
|
dense
|
||||||
<CardHeader className={cn(panelCardHeaderClassName, headerClassName)}>
|
spacing={spacing}
|
||||||
{title ? <CardTitle>{title}</CardTitle> : null}
|
className={cn(panelCardClassName, className)}
|
||||||
{description ? <CardDescription>{description}</CardDescription> : null}
|
>
|
||||||
{actions ? <CardAction>{actions}</CardAction> : null}
|
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
|
||||||
</CardHeader>
|
{hasHeader ? (
|
||||||
) : null}
|
<FrameHeader
|
||||||
{children != null && children !== false ? (
|
className={cn(
|
||||||
<CardContent className={contentClassName}>{children}</CardContent>
|
panelCardHeaderClassName,
|
||||||
) : null}
|
'flex-row items-start justify-between gap-3',
|
||||||
{footer ? (
|
headerClassName,
|
||||||
<CardFooter className={cn(panelCardFooterClassName, footerClassName)}>{footer}</CardFooter>
|
)}
|
||||||
) : null}
|
>
|
||||||
</Card>
|
<div className="flex min-w-0 flex-1 flex-col gap-px">
|
||||||
|
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
||||||
|
{description ? (
|
||||||
|
<FrameDescription>{description}</FrameDescription>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{actions ? (
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</FrameHeader>
|
||||||
|
) : null}
|
||||||
|
{children != null && children !== false ? (
|
||||||
|
<div className={cn('min-w-0 flex-1', contentClassName)}>{children}</div>
|
||||||
|
) : null}
|
||||||
|
{footer ? (
|
||||||
|
<FrameFooter className={cn(panelCardFooterClassName, footerClassName)}>
|
||||||
|
{footer}
|
||||||
|
</FrameFooter>
|
||||||
|
) : null}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export { DonutBreakdownCard } from './donut-breakdown-card'
|
export { DonutBreakdownCard } from './donut-breakdown-card'
|
||||||
export { IllustratedEmptyState } from './illustrated-empty-state'
|
export { IllustratedEmptyState } from './illustrated-empty-state'
|
||||||
export { KpiSparklineCard, type KpiSparklineMetric } from './kpi-sparkline-card'
|
export { KpiSparklineCard, type KpiSparklineMetric } from './kpi-sparkline-card'
|
||||||
|
export { KpiStatGrid, KpiStatCard, type KpiStatItem } from '@/components/kpi-stat-grid'
|
||||||
export { PanelCorners } from './panel-corners'
|
export { PanelCorners } from './panel-corners'
|
||||||
export { ProjectsEmptyState } from './projects-empty-state'
|
export { ProjectsEmptyState } from './projects-empty-state'
|
||||||
export { SegmentedProgressCard, type SegmentStat } from './segmented-progress-card'
|
export { SegmentedProgressCard, type SegmentStat } from './segmented-progress-card'
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
|
||||||
|
export interface DetailMetricCard {
|
||||||
|
id: string
|
||||||
|
icon: ReactNode
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
footer?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetailPanelProps {
|
||||||
|
children: ReactNode
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailPanelRoot({ children, className }: DetailPanelProps) {
|
||||||
|
return <div className={cn('flex flex-col gap-4 md:gap-6', className)}>{children}</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DetailPanelHeaderProps {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
actions?: ReactNode
|
||||||
|
children?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailPanelHeader({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
actions,
|
||||||
|
children,
|
||||||
|
}: DetailPanelHeaderProps) {
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm" className="w-full">
|
||||||
|
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-px">
|
||||||
|
<FrameTitle>{title}</FrameTitle>
|
||||||
|
{description ? (
|
||||||
|
<FrameDescription>{description}</FrameDescription>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{actions ? (
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||||
|
{actions}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</FrameHeader>
|
||||||
|
{children ? <FramePanel className="flex flex-col gap-4">{children}</FramePanel> : null}
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailPanelMetrics({ cards }: { cards: DetailMetricCard[] }) {
|
||||||
|
return (
|
||||||
|
<div className="@container w-full">
|
||||||
|
<div className="grid gap-4 @2xl:grid-cols-3">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<Frame key={card.id} spacing="sm">
|
||||||
|
<FrameHeader className="px-1! py-1!">
|
||||||
|
<div className="[&_svg]:text-muted-foreground flex items-center gap-2 [&_svg]:size-4">
|
||||||
|
{card.icon}
|
||||||
|
<span className="text-foreground text-sm font-medium">
|
||||||
|
{card.label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="flex flex-col gap-2">
|
||||||
|
<p className="text-muted-foreground text-xs leading-relaxed">
|
||||||
|
{card.description}
|
||||||
|
</p>
|
||||||
|
{card.footer}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DetailPanelSection({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
children: ReactNode
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="flex flex-col gap-3">
|
||||||
|
{title ? (
|
||||||
|
<header className="px-1">
|
||||||
|
<h2 className="text-sm font-semibold">{title}</h2>
|
||||||
|
{description ? (
|
||||||
|
<p className="text-muted-foreground text-sm">{description}</p>
|
||||||
|
) : null}
|
||||||
|
</header>
|
||||||
|
) : null}
|
||||||
|
{children}
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DetailPanel = Object.assign(DetailPanelRoot, {
|
||||||
|
Header: DetailPanelHeader,
|
||||||
|
Metrics: DetailPanelMetrics,
|
||||||
|
Section: DetailPanelSection,
|
||||||
|
})
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import type { Filter } from '@/components/reui/filters'
|
||||||
|
|
||||||
|
export function getActiveFilters(filters: Filter[]) {
|
||||||
|
return filters.filter((filter) => {
|
||||||
|
const { values } = filter
|
||||||
|
if (!values || values.length === 0) return false
|
||||||
|
if (values.every((value) => typeof value === 'string' && value.trim() === '')) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (values.every((value) => value === null || value === undefined)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (values.every((value) => Array.isArray(value) && value.length === 0)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyFiltersToData<T>(
|
||||||
|
data: T[],
|
||||||
|
filters: Filter[],
|
||||||
|
getFieldValue: (item: T, field: string) => unknown,
|
||||||
|
): T[] {
|
||||||
|
const active = getActiveFilters(filters)
|
||||||
|
let result = [...data]
|
||||||
|
|
||||||
|
for (const filter of active) {
|
||||||
|
const { field, operator, values } = filter
|
||||||
|
result = result.filter((item) => {
|
||||||
|
const raw = getFieldValue(item, field)
|
||||||
|
const fieldValue = raw != null ? raw : ''
|
||||||
|
|
||||||
|
switch (operator) {
|
||||||
|
case 'is':
|
||||||
|
return values.includes(fieldValue)
|
||||||
|
case 'is_not':
|
||||||
|
return !values.includes(fieldValue)
|
||||||
|
case 'is_any_of':
|
||||||
|
return values.some((value) => fieldValue === value)
|
||||||
|
case 'is_not_any_of':
|
||||||
|
return !values.some((value) => fieldValue === value)
|
||||||
|
case 'contains': {
|
||||||
|
const tokens = values
|
||||||
|
.map((value) => String(value).trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
if (tokens.length === 0) return true
|
||||||
|
return tokens.some((token) =>
|
||||||
|
String(fieldValue).toLowerCase().includes(token.toLowerCase()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
case 'not_contains':
|
||||||
|
return !values.some((value) =>
|
||||||
|
String(fieldValue).toLowerCase().includes(String(value).toLowerCase()),
|
||||||
|
)
|
||||||
|
case 'starts_with':
|
||||||
|
return values.some((value) =>
|
||||||
|
String(fieldValue).toLowerCase().startsWith(String(value).toLowerCase()),
|
||||||
|
)
|
||||||
|
case 'ends_with':
|
||||||
|
return values.some((value) =>
|
||||||
|
String(fieldValue).toLowerCase().endsWith(String(value).toLowerCase()),
|
||||||
|
)
|
||||||
|
case 'empty':
|
||||||
|
return fieldValue === '' || fieldValue == null
|
||||||
|
case 'not_empty':
|
||||||
|
return fieldValue !== '' && fieldValue != null
|
||||||
|
default:
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderSelectedCount(values: unknown[]) {
|
||||||
|
if (values.length === 0) return 'Выберите…'
|
||||||
|
if (values.length > 1) return `${values.length} выбрано`
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderSingleSelectedLabel(
|
||||||
|
values: unknown[],
|
||||||
|
options: { value: string; label: string }[],
|
||||||
|
) {
|
||||||
|
const state = renderSelectedCount(values)
|
||||||
|
if (state) return state
|
||||||
|
const option = options.find((item) => item.value === values[0])
|
||||||
|
return option?.label ?? String(values[0])
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
export {
|
||||||
|
applyFiltersToData,
|
||||||
|
getActiveFilters,
|
||||||
|
renderSelectedCount,
|
||||||
|
renderSingleSelectedLabel,
|
||||||
|
} from './filter-utils'
|
||||||
|
export {
|
||||||
|
ResourcePage,
|
||||||
|
type ResourcePageProps,
|
||||||
|
type ResourcePageTab,
|
||||||
|
} from './resource-page'
|
||||||
|
export {
|
||||||
|
KpiStatGrid,
|
||||||
|
KpiStatCard,
|
||||||
|
KpiStatCardTile,
|
||||||
|
kpiStatItemKey,
|
||||||
|
type KpiStatItem,
|
||||||
|
type KpiStatCardData,
|
||||||
|
type KpiStatCard as KpiStatCardType,
|
||||||
|
type KpiStatVariant,
|
||||||
|
type OpsKpiCard,
|
||||||
|
} from './kpi-stat-grid'
|
||||||
|
export { QuickActionGrid, type QuickActionItem } from './quick-action-grid'
|
||||||
|
export { OpsDashboard } from './ops-dashboard'
|
||||||
|
export { DetailPanel, type DetailMetricCard } from './detail-panel'
|
||||||
|
export { SettingsShell, type SettingsTabConfig } from './settings-shell'
|
||||||
@@ -0,0 +1,334 @@
|
|||||||
|
import type { KeyboardEvent, ReactNode } from 'react'
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import { Frame, FramePanel } from '@/components/reui/frame'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
||||||
|
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||||
|
|
||||||
|
export type KpiStatVariant = 'default' | 'warning' | 'destructive'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KPI tile data — horizontal compact hybrid (icon left + label/Badge + value).
|
||||||
|
* @see https://reui.io/preview/base/stats-12
|
||||||
|
*/
|
||||||
|
export type KpiStatItem = {
|
||||||
|
id?: string
|
||||||
|
label: ReactNode
|
||||||
|
value: ReactNode
|
||||||
|
hint?: ReactNode
|
||||||
|
to?: string
|
||||||
|
search?: Record<string, unknown>
|
||||||
|
onSelect?: () => void
|
||||||
|
onClick?: () => void
|
||||||
|
selected?: boolean
|
||||||
|
active?: boolean
|
||||||
|
icon?: ReactNode
|
||||||
|
iconClassName?: string
|
||||||
|
variant?: KpiStatVariant
|
||||||
|
footer?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CFDM-compatible card shape (id required). */
|
||||||
|
export type KpiStatCardData = KpiStatItem & { id: string }
|
||||||
|
|
||||||
|
/** @deprecated Use KpiStatCardData */
|
||||||
|
export type OpsKpiCard = KpiStatCardData
|
||||||
|
|
||||||
|
/** @deprecated Use KpiStatCardData — type alias for CFDM kit parity */
|
||||||
|
export type KpiStatCard = KpiStatCardData
|
||||||
|
|
||||||
|
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
||||||
|
|
||||||
|
const VALUE_VARIANT_CLASS: Record<KpiStatVariant, string> = {
|
||||||
|
default: 'text-foreground',
|
||||||
|
warning: 'text-warning',
|
||||||
|
destructive: 'text-destructive',
|
||||||
|
}
|
||||||
|
|
||||||
|
function kpiCols(count: number): string {
|
||||||
|
if (count <= 1) return 'grid-cols-1'
|
||||||
|
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
|
||||||
|
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 === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
|
||||||
|
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
|
||||||
|
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleCardKeyDown(onActivate: () => void, event: KeyboardEvent<HTMLDivElement>) {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault()
|
||||||
|
onActivate()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveActivate(item: KpiStatItem): (() => void) | undefined {
|
||||||
|
return item.onClick ?? item.onSelect
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSelected(item: KpiStatItem): boolean {
|
||||||
|
return Boolean(item.selected ?? item.active)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveFooter(item: KpiStatItem): ReactNode {
|
||||||
|
if (item.footer) return item.footer
|
||||||
|
if (typeof item.hint === 'string') {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
{item.hint}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (item.hint) return item.hint
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiStatCardBody({ item }: { item: KpiStatItem }) {
|
||||||
|
const footer = resolveFooter(item)
|
||||||
|
const valueVariant = item.variant ?? 'default'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative z-10 flex h-full items-start gap-3">
|
||||||
|
{item.icon ? (
|
||||||
|
<Item
|
||||||
|
className={cn(
|
||||||
|
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||||
|
item.iconClassName ?? DEFAULT_ICON_CLASS,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ItemMedia variant="icon" className="size-auto">
|
||||||
|
{item.icon}
|
||||||
|
</ItemMedia>
|
||||||
|
</Item>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<div className="text-muted-foreground text-sm font-medium">{item.label}</div>
|
||||||
|
{footer ? <div className="shrink-0">{footer}</div> : null}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'text-2xl leading-none font-bold tabular-nums',
|
||||||
|
VALUE_VARIANT_CLASS[valueVariant],
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.value}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function panelClassName(item: KpiStatItem, className?: string) {
|
||||||
|
const onActivate = resolveActivate(item)
|
||||||
|
const clickable = Boolean(item.to || onActivate)
|
||||||
|
const selected = isSelected(item)
|
||||||
|
|
||||||
|
return cn(
|
||||||
|
'relative isolate flex h-full flex-col',
|
||||||
|
clickable &&
|
||||||
|
'hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2',
|
||||||
|
selected && 'ring-primary/30 bg-muted/30 ring-1',
|
||||||
|
className,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Single KPI tile — used for embedded / standalone contexts. */
|
||||||
|
export function KpiStatCardTile({
|
||||||
|
item,
|
||||||
|
embedded = false,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
item: KpiStatItem
|
||||||
|
embedded?: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
const onActivate = resolveActivate(item)
|
||||||
|
const panelClass = panelClassName(item, className)
|
||||||
|
|
||||||
|
let panel: ReactNode
|
||||||
|
|
||||||
|
if (item.to) {
|
||||||
|
panel = (
|
||||||
|
<FramePanel className={panelClass}>
|
||||||
|
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</Link>
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
} else if (onActivate) {
|
||||||
|
panel = (
|
||||||
|
<FramePanel
|
||||||
|
className={panelClass}
|
||||||
|
onClick={onActivate}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
||||||
|
>
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
panel = (
|
||||||
|
<FramePanel className={panelClass}>
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return <Frame className="h-full ring-1 ring-foreground/10">{panel}</Frame>
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Frame className="h-full">{panel}</Frame>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @deprecated Prefer KpiStatCardTile — kept for existing imports */
|
||||||
|
export function KpiStatCard({
|
||||||
|
item,
|
||||||
|
embedded = false,
|
||||||
|
className,
|
||||||
|
}: {
|
||||||
|
item: KpiStatItem
|
||||||
|
embedded?: boolean
|
||||||
|
className?: string
|
||||||
|
}) {
|
||||||
|
return <KpiStatCardTile item={item} embedded={embedded} className={className} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiStatGridSkeleton({ count }: { count: number }) {
|
||||||
|
return (
|
||||||
|
<Frame className="@container w-full">
|
||||||
|
<div className={cn('grid gap-2', kpiCols(count))}>
|
||||||
|
{Array.from({ length: count }).map((_, index) => (
|
||||||
|
<FramePanel key={index} className="flex items-start gap-3">
|
||||||
|
<Skeleton className="size-10.5 shrink-0 rounded-lg" />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<Skeleton className="h-4 w-20" />
|
||||||
|
<Skeleton className="h-4.5 w-14 rounded-full" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-7 w-16" />
|
||||||
|
</div>
|
||||||
|
</FramePanel>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
interface KpiStatGridProps {
|
||||||
|
/** EvoBGP primary API */
|
||||||
|
items?: KpiStatItem[]
|
||||||
|
/** CFDM-compatible API */
|
||||||
|
cards?: KpiStatCardData[]
|
||||||
|
isLoading?: boolean
|
||||||
|
emptyMessage?: ReactNode
|
||||||
|
emptyIcon?: ReactNode
|
||||||
|
className?: string
|
||||||
|
skeletonCount?: number
|
||||||
|
/** Wrap each tile in its own Frame (analytics panels). */
|
||||||
|
embedded?: boolean
|
||||||
|
'aria-label'?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function KpiStatCardItem({ item }: { item: KpiStatItem }) {
|
||||||
|
const onActivate = resolveActivate(item)
|
||||||
|
const panelClass = panelClassName(item)
|
||||||
|
|
||||||
|
if (item.to) {
|
||||||
|
return (
|
||||||
|
<FramePanel className={panelClass}>
|
||||||
|
<Link to={item.to} search={item.search} className="focus-visible:outline-none">
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</Link>
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (onActivate) {
|
||||||
|
return (
|
||||||
|
<FramePanel
|
||||||
|
className={panelClass}
|
||||||
|
onClick={onActivate}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
onKeyDown={(e) => handleCardKeyDown(onActivate, e)}
|
||||||
|
>
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<FramePanel className={panelClass}>
|
||||||
|
<KpiStatCardBody item={item} />
|
||||||
|
</FramePanel>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hybrid KPI — EvoBGP visual + horizontal compact layout (icon left).
|
||||||
|
* Preview: https://reui.io/preview/base/stats-12
|
||||||
|
*/
|
||||||
|
export function KpiStatGrid({
|
||||||
|
items,
|
||||||
|
cards,
|
||||||
|
isLoading = false,
|
||||||
|
emptyMessage,
|
||||||
|
emptyIcon,
|
||||||
|
className,
|
||||||
|
skeletonCount = 4,
|
||||||
|
embedded = false,
|
||||||
|
'aria-label': ariaLabel,
|
||||||
|
}: KpiStatGridProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return <KpiStatGridSkeleton count={skeletonCount} />
|
||||||
|
}
|
||||||
|
|
||||||
|
const list = items ?? cards ?? []
|
||||||
|
|
||||||
|
if (list.length === 0 && (emptyMessage || emptyIcon)) {
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm" className={cn('w-full', className)}>
|
||||||
|
<FramePanel className="flex items-center gap-3 p-4">
|
||||||
|
{emptyIcon}
|
||||||
|
{emptyMessage ? (
|
||||||
|
<p className="text-muted-foreground text-sm">{emptyMessage}</p>
|
||||||
|
) : null}
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (embedded) {
|
||||||
|
return (
|
||||||
|
<section aria-label={ariaLabel} className={cn('@container w-full', className)}>
|
||||||
|
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
||||||
|
{list.map((item, index) => (
|
||||||
|
<KpiStatCardTile key={kpiStatItemKey(item, index)} item={item} embedded />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame className={cn('@container w-full', className)} aria-label={ariaLabel}>
|
||||||
|
<div className={cn('grid gap-2', kpiCols(list.length || 1))}>
|
||||||
|
{list.map((item, index) => (
|
||||||
|
<KpiStatCardItem key={kpiStatItemKey(item, index)} item={item} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function kpiStatItemKey(item: KpiStatItem, index: number): string {
|
||||||
|
if (item.id) return item.id
|
||||||
|
if (typeof item.label === 'string') return item.label
|
||||||
|
return `kpi-${index}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||||
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { KpiStatGrid, type KpiStatCardData } from './kpi-stat-grid'
|
||||||
|
|
||||||
|
export type OpsKpiCard = KpiStatCardData
|
||||||
|
|
||||||
|
interface OpsDashboardProps {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
kpiCards: OpsKpiCard[]
|
||||||
|
charts: ReactNode
|
||||||
|
queue: ReactNode
|
||||||
|
queueTitle?: string
|
||||||
|
queueDescription?: string
|
||||||
|
isLoading?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const rootClassName =
|
||||||
|
'text-foreground @container flex w-full flex-col gap-2 md:gap-3'
|
||||||
|
|
||||||
|
function OpsDashboardSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className={rootClassName}>
|
||||||
|
<header className="px-1">
|
||||||
|
<Skeleton className="h-7 w-56" />
|
||||||
|
<Skeleton className="mt-2 h-4 w-80 max-w-full" />
|
||||||
|
</header>
|
||||||
|
<KpiStatGrid cards={[]} isLoading skeletonCount={4} />
|
||||||
|
<div className="grid gap-2 @3xl:grid-cols-2">
|
||||||
|
<Skeleton className="h-64 w-full rounded-xl" />
|
||||||
|
<Skeleton className="h-64 w-full rounded-xl" />
|
||||||
|
</div>
|
||||||
|
<Skeleton className="h-48 w-full rounded-xl" />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OpsDashboard({
|
||||||
|
title = 'Панель управления',
|
||||||
|
description = 'Обзор модулей, сети, задач и состояния плоскости управления',
|
||||||
|
kpiCards,
|
||||||
|
charts,
|
||||||
|
queue,
|
||||||
|
queueTitle = 'Требуют внимания',
|
||||||
|
queueDescription = 'Проблемы BGP, сбои задач и офлайн-спикеры',
|
||||||
|
isLoading = false,
|
||||||
|
}: OpsDashboardProps) {
|
||||||
|
if (isLoading) {
|
||||||
|
return <OpsDashboardSkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={rootClassName}>
|
||||||
|
<PageHeader title={title} description={description} />
|
||||||
|
|
||||||
|
<section aria-label="Ключевые метрики">
|
||||||
|
<KpiStatGrid cards={kpiCards} skeletonCount={4} />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section
|
||||||
|
aria-label="Аналитика"
|
||||||
|
className="grid min-w-0 items-start gap-2 @3xl:grid-cols-2"
|
||||||
|
>
|
||||||
|
{charts}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-label={queueTitle}>
|
||||||
|
<Frame dense spacing="sm" className="w-full">
|
||||||
|
<FrameHeader>
|
||||||
|
<FrameTitle>{queueTitle}</FrameTitle>
|
||||||
|
<FrameDescription>{queueDescription}</FrameDescription>
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel>{queue}</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { Item, ItemMedia } from '@evobgp/ui/components/item'
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
|
||||||
|
export interface QuickActionItem {
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
description: string
|
||||||
|
to: string
|
||||||
|
search?: Record<string, unknown>
|
||||||
|
icon?: ReactNode
|
||||||
|
iconClassName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface QuickActionGridProps {
|
||||||
|
actions: QuickActionItem[]
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
className?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_ICON_CLASS = 'text-muted-foreground [&_svg]:text-current'
|
||||||
|
|
||||||
|
function kpiCols(count: number): string {
|
||||||
|
if (count <= 1) return 'grid-cols-1'
|
||||||
|
if (count === 2) return 'grid-cols-1 @xl:grid-cols-2'
|
||||||
|
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 === 5) return 'grid-cols-2 @3xl:grid-cols-3 xl:grid-cols-5'
|
||||||
|
if (count === 6) return 'grid-cols-2 sm:grid-cols-3 xl:grid-cols-6'
|
||||||
|
return 'grid-cols-2 sm:grid-cols-3 lg:grid-cols-4'
|
||||||
|
}
|
||||||
|
|
||||||
|
function QuickActionBody({ action }: { action: QuickActionItem }) {
|
||||||
|
return (
|
||||||
|
<div className="relative z-10 flex h-full items-start gap-3">
|
||||||
|
{action.icon ? (
|
||||||
|
<Item
|
||||||
|
className={cn(
|
||||||
|
'border-background bg-muted flex size-10.5 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||||
|
action.iconClassName ?? DEFAULT_ICON_CLASS,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<ItemMedia variant="icon" className="size-auto">
|
||||||
|
{action.icon}
|
||||||
|
</ItemMedia>
|
||||||
|
</Item>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||||
|
<div className="flex items-start justify-between gap-2">
|
||||||
|
<span className="text-foreground text-sm font-medium">{action.title}</span>
|
||||||
|
<Badge variant="outline" size="sm" className="shrink-0">
|
||||||
|
Перейти
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground line-clamp-2 text-xs leading-relaxed">
|
||||||
|
{action.description}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* KPI-like quick actions strip (horizontal Frame tiles).
|
||||||
|
* Preview: https://reui.io/preview/base/stats-12
|
||||||
|
*/
|
||||||
|
export function QuickActionGrid({
|
||||||
|
actions,
|
||||||
|
title = 'Быстрые действия',
|
||||||
|
description,
|
||||||
|
className,
|
||||||
|
}: QuickActionGridProps) {
|
||||||
|
if (actions.length === 0) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Frame dense spacing="sm" className={cn('@container w-full', className)}>
|
||||||
|
{(title || description) && (
|
||||||
|
<FrameHeader>
|
||||||
|
{title ? <FrameTitle>{title}</FrameTitle> : null}
|
||||||
|
{description ? <FrameDescription>{description}</FrameDescription> : null}
|
||||||
|
</FrameHeader>
|
||||||
|
)}
|
||||||
|
<div className={cn('grid gap-2', kpiCols(actions.length))}>
|
||||||
|
{actions.map((action) => (
|
||||||
|
<FramePanel
|
||||||
|
key={action.id}
|
||||||
|
className="relative isolate flex h-full flex-col hover:bg-muted/40 focus-within:ring-ring cursor-pointer transition-colors focus-within:ring-2"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
to={action.to}
|
||||||
|
search={action.search}
|
||||||
|
className="focus-visible:outline-none"
|
||||||
|
aria-label={`${action.title}: ${action.description}`}
|
||||||
|
>
|
||||||
|
<QuickActionBody action={action} />
|
||||||
|
</Link>
|
||||||
|
</FramePanel>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,370 @@
|
|||||||
|
import { useCallback, useMemo, useState, type ReactNode } from 'react'
|
||||||
|
import {
|
||||||
|
getCoreRowModel,
|
||||||
|
getPaginationRowModel,
|
||||||
|
getSortedRowModel,
|
||||||
|
useReactTable,
|
||||||
|
type ColumnDef,
|
||||||
|
type PaginationState,
|
||||||
|
type RowSelectionState,
|
||||||
|
type SortingState,
|
||||||
|
} from '@tanstack/react-table'
|
||||||
|
import { CircleAlertIcon, FilterIcon, FilterXIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { DataGrid } from '@/components/reui/data-grid/data-grid'
|
||||||
|
import { DataGridPagination } from '@/components/reui/data-grid/data-grid-pagination'
|
||||||
|
import { DataGridScrollArea } from '@/components/reui/data-grid/data-grid-scroll-area'
|
||||||
|
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
|
||||||
|
import {
|
||||||
|
Filters,
|
||||||
|
type Filter,
|
||||||
|
type FilterFieldConfig,
|
||||||
|
} from '@/components/reui/filters'
|
||||||
|
import {
|
||||||
|
Frame,
|
||||||
|
FrameDescription,
|
||||||
|
FrameFooter,
|
||||||
|
FrameHeader,
|
||||||
|
FramePanel,
|
||||||
|
FrameTitle,
|
||||||
|
} from '@/components/reui/frame'
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { Separator } from '@evobgp/ui/components/separator'
|
||||||
|
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
AlertDescription,
|
||||||
|
AlertTitle,
|
||||||
|
} from '@/components/reui/alert'
|
||||||
|
import { EmptyState } from '@/components/empty-state'
|
||||||
|
import { applyFiltersToData } from './filter-utils'
|
||||||
|
|
||||||
|
export interface ResourcePageTab {
|
||||||
|
id: string
|
||||||
|
label: string
|
||||||
|
count?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResourcePageProps<T extends object> {
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
tabs?: ResourcePageTab[]
|
||||||
|
activeTab?: string
|
||||||
|
onTabChange?: (tabId: string) => void
|
||||||
|
tabFilter?: (item: T, tabId: string) => boolean
|
||||||
|
filterFields: FilterFieldConfig[]
|
||||||
|
filters: Filter[]
|
||||||
|
onFiltersChange: (filters: Filter[]) => void
|
||||||
|
onClearFilters?: () => void
|
||||||
|
getFilterFieldValue: (item: T, field: string) => unknown
|
||||||
|
columns: ColumnDef<T, unknown>[]
|
||||||
|
data: T[]
|
||||||
|
getRowId: (row: T) => string
|
||||||
|
isLoading?: boolean
|
||||||
|
isError?: boolean
|
||||||
|
error?: Error | null
|
||||||
|
onRetry?: () => void
|
||||||
|
primaryAction?: ReactNode
|
||||||
|
emptyState?: { title: string; description?: string; action?: ReactNode }
|
||||||
|
pageSize?: number
|
||||||
|
enableRowSelection?: boolean
|
||||||
|
selectionToolbar?: (ctx: {
|
||||||
|
selectedIds: string[]
|
||||||
|
selectedCount: number
|
||||||
|
clearSelection: () => void
|
||||||
|
}) => ReactNode
|
||||||
|
toolbarExtra?: ReactNode
|
||||||
|
hideHeader?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
function ResourcePageSkeleton() {
|
||||||
|
return (
|
||||||
|
<Frame dense variant="default" spacing="sm" className="w-full">
|
||||||
|
<FrameHeader>
|
||||||
|
<Skeleton className="h-5 w-48" />
|
||||||
|
<Skeleton className="mt-1 h-4 w-72" />
|
||||||
|
</FrameHeader>
|
||||||
|
<FramePanel className="p-0">
|
||||||
|
<div className="flex flex-col gap-3 p-4">
|
||||||
|
<Skeleton className="h-9 w-full max-w-md" />
|
||||||
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-10 w-full" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ResourcePage<T extends object>({
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
tabs,
|
||||||
|
activeTab: controlledTab,
|
||||||
|
onTabChange,
|
||||||
|
tabFilter,
|
||||||
|
filterFields,
|
||||||
|
filters,
|
||||||
|
onFiltersChange,
|
||||||
|
onClearFilters,
|
||||||
|
getFilterFieldValue,
|
||||||
|
columns,
|
||||||
|
data,
|
||||||
|
getRowId,
|
||||||
|
isLoading = false,
|
||||||
|
isError = false,
|
||||||
|
error = null,
|
||||||
|
onRetry,
|
||||||
|
primaryAction,
|
||||||
|
emptyState,
|
||||||
|
pageSize = 10,
|
||||||
|
enableRowSelection = false,
|
||||||
|
selectionToolbar,
|
||||||
|
toolbarExtra,
|
||||||
|
hideHeader = false,
|
||||||
|
}: ResourcePageProps<T>) {
|
||||||
|
const [internalTab, setInternalTab] = useState(tabs?.[0]?.id ?? 'all')
|
||||||
|
const activeTab = controlledTab ?? internalTab
|
||||||
|
|
||||||
|
const [sorting, setSorting] = useState<SortingState>([])
|
||||||
|
const [rowSelection, setRowSelection] = useState<RowSelectionState>({})
|
||||||
|
const [pagination, setPagination] = useState<PaginationState>({
|
||||||
|
pageIndex: 0,
|
||||||
|
pageSize,
|
||||||
|
})
|
||||||
|
|
||||||
|
const resetPagination = useCallback(() => {
|
||||||
|
setPagination((current) =>
|
||||||
|
current.pageIndex === 0 ? current : { ...current, pageIndex: 0 },
|
||||||
|
)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const filteredData = useMemo(() => {
|
||||||
|
let result = applyFiltersToData(data, filters, getFilterFieldValue)
|
||||||
|
if (tabs && tabs.length > 0 && tabFilter && activeTab !== 'all') {
|
||||||
|
result = result.filter((item) => tabFilter(item, activeTab))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}, [data, filters, getFilterFieldValue, tabs, tabFilter, activeTab])
|
||||||
|
|
||||||
|
const tabCounts = useMemo(() => {
|
||||||
|
if (!tabs?.length || !tabFilter) return {}
|
||||||
|
const base = applyFiltersToData(data, filters, getFilterFieldValue)
|
||||||
|
const counts: Record<string, number> = {}
|
||||||
|
for (const tab of tabs) {
|
||||||
|
counts[tab.id] =
|
||||||
|
tab.id === 'all'
|
||||||
|
? base.length
|
||||||
|
: base.filter((item) => tabFilter(item, tab.id)).length
|
||||||
|
}
|
||||||
|
return counts
|
||||||
|
}, [tabs, tabFilter, data, filters, getFilterFieldValue])
|
||||||
|
|
||||||
|
const selectedIds = useMemo(
|
||||||
|
() => Object.keys(rowSelection).filter((id) => rowSelection[id]),
|
||||||
|
[rowSelection],
|
||||||
|
)
|
||||||
|
|
||||||
|
const selectedCount = selectedIds.length
|
||||||
|
|
||||||
|
const clearSelection = useCallback(() => {
|
||||||
|
setRowSelection({})
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const table = useReactTable({
|
||||||
|
data: filteredData,
|
||||||
|
columns,
|
||||||
|
getRowId,
|
||||||
|
state: { sorting, rowSelection, pagination },
|
||||||
|
enableRowSelection,
|
||||||
|
onSortingChange: setSorting,
|
||||||
|
onRowSelectionChange: setRowSelection,
|
||||||
|
onPaginationChange: setPagination,
|
||||||
|
getCoreRowModel: getCoreRowModel(),
|
||||||
|
getSortedRowModel: getSortedRowModel(),
|
||||||
|
getPaginationRowModel: getPaginationRowModel(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleTabChange = useCallback(
|
||||||
|
(value: string) => {
|
||||||
|
if (onTabChange) onTabChange(value)
|
||||||
|
else setInternalTab(value)
|
||||||
|
resetPagination()
|
||||||
|
},
|
||||||
|
[onTabChange, resetPagination],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleFiltersChange = useCallback(
|
||||||
|
(next: Filter[]) => {
|
||||||
|
onFiltersChange(next)
|
||||||
|
resetPagination()
|
||||||
|
},
|
||||||
|
[onFiltersChange, resetPagination],
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleClear = useCallback(() => {
|
||||||
|
onClearFilters?.()
|
||||||
|
resetPagination()
|
||||||
|
}, [onClearFilters, resetPagination])
|
||||||
|
|
||||||
|
const countedTabs = useMemo(
|
||||||
|
() =>
|
||||||
|
(tabs ?? []).map((tab) => ({
|
||||||
|
id: tab.id,
|
||||||
|
label: tab.label,
|
||||||
|
count: tabCounts[tab.id] ?? tab.count ?? 0,
|
||||||
|
})),
|
||||||
|
[tabs, tabCounts],
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <ResourcePageSkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isError) {
|
||||||
|
return (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<CircleAlertIcon />
|
||||||
|
<AlertTitle>Ошибка загрузки</AlertTitle>
|
||||||
|
<AlertDescription className="flex flex-col gap-2">
|
||||||
|
<span>{error?.message ?? 'Не удалось загрузить данные'}</span>
|
||||||
|
{onRetry ? (
|
||||||
|
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
|
||||||
|
Повторить
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.length === 0 && emptyState) {
|
||||||
|
return (
|
||||||
|
<EmptyState
|
||||||
|
title={emptyState.title}
|
||||||
|
description={emptyState.description}
|
||||||
|
action={emptyState.action}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const emptyMessage = 'Нет записей по выбранным фильтрам.'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full">
|
||||||
|
{selectionToolbar && selectedCount > 0
|
||||||
|
? selectionToolbar({
|
||||||
|
selectedIds,
|
||||||
|
selectedCount,
|
||||||
|
clearSelection,
|
||||||
|
})
|
||||||
|
: null}
|
||||||
|
<DataGrid
|
||||||
|
table={table}
|
||||||
|
recordCount={filteredData.length}
|
||||||
|
emptyMessage={emptyMessage}
|
||||||
|
tableLayout={{ dense: true }}
|
||||||
|
>
|
||||||
|
<Frame dense variant="default" spacing="sm" className="w-full">
|
||||||
|
{!hideHeader ? (
|
||||||
|
<FrameHeader className="flex-row items-start justify-between gap-3">
|
||||||
|
<div className="flex min-w-0 flex-col gap-px">
|
||||||
|
<FrameTitle className="text-balance">{title}</FrameTitle>
|
||||||
|
{description ? (
|
||||||
|
<FrameDescription className="flex flex-wrap items-center gap-1.5 text-xs text-pretty">
|
||||||
|
<span>{description}</span>
|
||||||
|
<span
|
||||||
|
className="bg-input size-1 shrink-0 rounded-full"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{filteredData.length} записей
|
||||||
|
</span>
|
||||||
|
{selectedCount > 0 ? (
|
||||||
|
<>
|
||||||
|
<span
|
||||||
|
className="bg-input size-1 shrink-0 rounded-full"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<span>{selectedCount} выбрано</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</FrameDescription>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{primaryAction ? (
|
||||||
|
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
|
||||||
|
{primaryAction}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</FrameHeader>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<FramePanel className="p-0 shadow-none!">
|
||||||
|
{countedTabs.length > 0 ? (
|
||||||
|
<>
|
||||||
|
<div className="px-(--frame-panel-header-px) pt-(--frame-panel-header-py)">
|
||||||
|
<CountedLineTabs
|
||||||
|
tabs={countedTabs}
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={handleTabChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Separator />
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3 px-(--frame-panel-header-px) py-(--frame-panel-header-py)">
|
||||||
|
<Filters
|
||||||
|
filters={filters}
|
||||||
|
fields={filterFields}
|
||||||
|
onChange={handleFiltersChange}
|
||||||
|
size="default"
|
||||||
|
trigger={
|
||||||
|
<Button type="button" variant="outline" aria-label="Фильтры">
|
||||||
|
<FilterIcon className="size-4" aria-hidden="true" />
|
||||||
|
Фильтры
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||||
|
{toolbarExtra}
|
||||||
|
{selectedCount > 0 ? (
|
||||||
|
<Badge size="sm" variant="secondary">
|
||||||
|
{selectedCount} выбрано
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
{onClearFilters ? (
|
||||||
|
<Button type="button" variant="outline" onClick={handleClear}>
|
||||||
|
<FilterXIcon className="size-4" aria-hidden="true" />
|
||||||
|
Сбросить
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<DataGridScrollArea>
|
||||||
|
<DataGridTable />
|
||||||
|
</DataGridScrollArea>
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
<FrameFooter>
|
||||||
|
<DataGridPagination
|
||||||
|
sizes={[5, 10, 20, 50]}
|
||||||
|
rowsPerPageLabel="Строк на странице"
|
||||||
|
info="{from} - {to} of {count}"
|
||||||
|
previousPageLabel="Предыдущая"
|
||||||
|
nextPageLabel="Следующая"
|
||||||
|
/>
|
||||||
|
</FrameFooter>
|
||||||
|
</FramePanel>
|
||||||
|
</Frame>
|
||||||
|
</DataGrid>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import type { ReactNode } from 'react'
|
||||||
|
import { Link, Outlet, useRouterState } from '@tanstack/react-router'
|
||||||
|
import { ServerCogIcon, SettingsIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { useIsMobile } from '@evobgp/ui/hooks/use-mobile'
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
import { PageShell } from '@/components/page-shell'
|
||||||
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { Separator } from '@evobgp/ui/components/separator'
|
||||||
|
|
||||||
|
export interface SettingsTabConfig {
|
||||||
|
id: string
|
||||||
|
to: string
|
||||||
|
label: string
|
||||||
|
icon?: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default nav for EvoBGP settings routes (`/settings`, `/tenant-settings`). */
|
||||||
|
const DEFAULT_TABS: SettingsTabConfig[] = [
|
||||||
|
{
|
||||||
|
id: 'ui',
|
||||||
|
to: '/settings',
|
||||||
|
label: 'Настройки UI',
|
||||||
|
icon: <SettingsIcon className="size-4" aria-hidden="true" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'tenant',
|
||||||
|
to: '/tenant-settings',
|
||||||
|
label: 'Настройки BIRD',
|
||||||
|
icon: <ServerCogIcon className="size-4" aria-hidden="true" />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
interface SettingsShellProps {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
tabs?: SettingsTabConfig[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsShell({
|
||||||
|
title = 'Настройки',
|
||||||
|
description = 'Подключение UI и параметры плоскости управления',
|
||||||
|
tabs = DEFAULT_TABS,
|
||||||
|
}: SettingsShellProps) {
|
||||||
|
const isMobile = useIsMobile()
|
||||||
|
const pathname = useRouterState({ select: (s) => s.location.pathname })
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PageShell>
|
||||||
|
<div className="mx-auto flex w-full max-w-4xl flex-col gap-5">
|
||||||
|
<div className="flex flex-col gap-3">
|
||||||
|
<PageHeader title={title} description={description} />
|
||||||
|
<Separator />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex gap-5',
|
||||||
|
isMobile ? 'flex-col' : 'flex-row items-start',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tabs.length > 1 ? (
|
||||||
|
<nav
|
||||||
|
aria-label="Разделы настроек"
|
||||||
|
className={cn(
|
||||||
|
'flex gap-1',
|
||||||
|
isMobile
|
||||||
|
? 'scrollbar-none -mx-1 overflow-x-auto overflow-y-hidden pb-1'
|
||||||
|
: 'w-44 shrink-0 flex-col',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const isActive = pathname.startsWith(tab.to)
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={tab.id}
|
||||||
|
to={tab.to}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center gap-2 rounded-lg px-3 py-2 text-sm transition-colors',
|
||||||
|
isMobile && 'shrink-0',
|
||||||
|
!isMobile && 'w-full',
|
||||||
|
isActive
|
||||||
|
? 'bg-muted text-foreground font-medium shadow-sm ring-1 ring-border/60'
|
||||||
|
: 'text-muted-foreground hover:bg-muted/60 hover:text-foreground',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{tab.icon}
|
||||||
|
{tab.label}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</PageShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
import { cva, type VariantProps } from "class-variance-authority"
|
||||||
|
|
||||||
|
import { cn } from "@evobgp/ui/lib/utils"
|
||||||
|
|
||||||
|
const alertVariants = cva(
|
||||||
|
[
|
||||||
|
"relative w-full text-sm border has-[>svg]:grid-cols-[calc(var(--spacing)*3)_1fr] grid-cols-[0_1fr] grid gap-y-0.5 items-center [&>svg:not([class*=size-])]:size-4",
|
||||||
|
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:[&_[data-slot=alert-action]]:sm:row-end-3",
|
||||||
|
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:items-start",
|
||||||
|
"has-[>[data-slot=alert-title]+[data-slot=alert-description]]:[&_svg]:translate-y-0.5",
|
||||||
|
"rounded-lg",
|
||||||
|
"px-3",
|
||||||
|
"py-2.5",
|
||||||
|
"has-[>svg]:gap-x-2.5",
|
||||||
|
],
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
variant: {
|
||||||
|
default: "bg-card text-card-foreground",
|
||||||
|
destructive:
|
||||||
|
"border-destructive/30 bg-destructive/4 [&>svg]:text-destructive",
|
||||||
|
info: "border-info/30 bg-info/4 [&>svg]:text-info",
|
||||||
|
success: "border-success/30 bg-success/4 [&>svg]:text-success",
|
||||||
|
warning: "border-warning/30 bg-warning/4 [&>svg]:text-warning",
|
||||||
|
invert:
|
||||||
|
"border-invert bg-invert text-invert-foreground [&_[data-slot=alert-description]]:text-invert-foreground/70",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
variant: "default",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
function Alert({
|
||||||
|
className,
|
||||||
|
variant,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div"> & VariantProps<typeof alertVariants>) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert"
|
||||||
|
role="alert"
|
||||||
|
className={cn(alertVariants({ variant }), className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-title"
|
||||||
|
className={cn(
|
||||||
|
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertDescription({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-description"
|
||||||
|
className={cn(
|
||||||
|
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
data-slot="alert-action"
|
||||||
|
className={cn(
|
||||||
|
"flex gap-1.5 max-sm:col-start-2 max-sm:mt-2 max-sm:justify-start sm:col-start-3 sm:row-start-1 sm:justify-end sm:self-center",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Alert, AlertTitle, AlertDescription, AlertAction }
|
||||||
@@ -46,9 +46,9 @@ export function ScheduleJobsCard({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<DataGridCard title="Задачи" description="Последние задачи из API">
|
<DataGridCard title="Задачи" description="Последние задачи из API">
|
||||||
<div className="border-b px-5 py-3">
|
<div className="px-5 pt-3">
|
||||||
<Tabs value={tab} onValueChange={(v) => setTab(v as JobTab)}>
|
<Tabs value={tab} onValueChange={(v) => setTab(v as JobTab)} className="w-full">
|
||||||
<TabsList>
|
<TabsList variant="line" className="w-full justify-start gap-6">
|
||||||
<TabsTrigger value="all">Все ({counts.all})</TabsTrigger>
|
<TabsTrigger value="all">Все ({counts.all})</TabsTrigger>
|
||||||
<TabsTrigger value="refresh">Обновление ({counts.refresh})</TabsTrigger>
|
<TabsTrigger value="refresh">Обновление ({counts.refresh})</TabsTrigger>
|
||||||
<TabsTrigger value="failed">С ошибкой ({counts.failed})</TabsTrigger>
|
<TabsTrigger value="failed">С ошибкой ({counts.failed})</TabsTrigger>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { ReactElement, ReactNode } from 'react'
|
import type { ReactElement, ReactNode } from 'react'
|
||||||
import { Card, CardContent } from '@evobgp/ui/components/card'
|
|
||||||
import { cn } from '@evobgp/ui/lib/utils'
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { TruncatedText } from '@/components/truncated-text'
|
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
|
||||||
|
|
||||||
export interface SectionCardItem {
|
export interface SectionCardItem {
|
||||||
label: ReactNode
|
label: ReactNode
|
||||||
@@ -14,97 +14,48 @@ export interface SectionCardItem {
|
|||||||
onClick?: () => void
|
onClick?: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
const VARIANT_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
|
const VARIANT_ICON_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
|
||||||
default: '',
|
default: 'text-muted-foreground',
|
||||||
warning: 'border-warning/50',
|
warning: 'text-warning',
|
||||||
destructive: 'border-destructive/50',
|
|
||||||
}
|
|
||||||
|
|
||||||
function sectionGridClass(count: number): string {
|
|
||||||
if (count <= 1) return 'grid-cols-1'
|
|
||||||
if (count === 2) return 'sm:grid-cols-2'
|
|
||||||
if (count === 3) return 'sm:grid-cols-2 lg:grid-cols-3'
|
|
||||||
if (count === 4) return 'sm:grid-cols-2 lg:grid-cols-4'
|
|
||||||
if (count === 5) return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5'
|
|
||||||
if (count === 6) return 'sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6'
|
|
||||||
return 'sm:grid-cols-2 lg:grid-cols-3'
|
|
||||||
}
|
|
||||||
|
|
||||||
const VALUE_VARIANT_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
|
|
||||||
default: '',
|
|
||||||
warning: 'text-warning-foreground',
|
|
||||||
destructive: 'text-destructive',
|
destructive: 'text-destructive',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hintToFooter(hint: ReactNode) {
|
||||||
|
if (typeof hint === 'string') {
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
{hint}
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return hint
|
||||||
|
}
|
||||||
|
|
||||||
|
function toKpiStatItem(item: SectionCardItem, index: number): KpiStatItem {
|
||||||
|
const variant = item.variant ?? 'default'
|
||||||
|
const footer =
|
||||||
|
item.badge ?? (item.hint ? hintToFooter(item.hint) : undefined)
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: typeof item.label === 'string' ? item.label : `section-${index}`,
|
||||||
|
icon: item.icon,
|
||||||
|
iconClassName: VARIANT_ICON_CLASS[variant],
|
||||||
|
value: item.value,
|
||||||
|
label: item.label,
|
||||||
|
variant,
|
||||||
|
footer,
|
||||||
|
active: item.active,
|
||||||
|
onClick: item.onClick,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** KPI row for route sections — ReUI stats-12 / dashboard PRO pattern. */
|
||||||
export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) {
|
export function SectionCards({ items, className }: { items: SectionCardItem[]; className?: string }) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('grid gap-3', sectionGridClass(items.length), className)}>
|
<KpiStatGrid
|
||||||
{items.map((item, idx) => {
|
items={items.map(toKpiStatItem)}
|
||||||
const clickable = Boolean(item.onClick)
|
className={className}
|
||||||
const content = (
|
aria-label="Показатели"
|
||||||
<CardContent className="flex items-start gap-3 px-4 py-3">
|
/>
|
||||||
{item.icon ? (
|
|
||||||
<span className="flex size-7 shrink-0 items-center justify-center rounded-md bg-muted/60 text-muted-foreground">
|
|
||||||
{item.icon}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
|
||||||
<div className="flex items-center justify-between gap-2">
|
|
||||||
{typeof item.label === 'string' ? (
|
|
||||||
<TruncatedText className="text-xs text-muted-foreground">{item.label}</TruncatedText>
|
|
||||||
) : (
|
|
||||||
<span className="truncate text-xs text-muted-foreground">{item.label}</span>
|
|
||||||
)}
|
|
||||||
{item.badge ? <span className="shrink-0">{item.badge}</span> : null}
|
|
||||||
</div>
|
|
||||||
<div className="flex min-w-0 items-baseline gap-1.5">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1 text-lg font-semibold tabular-nums',
|
|
||||||
VALUE_VARIANT_CLASS[item.variant ?? 'default'],
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.value}
|
|
||||||
</span>
|
|
||||||
{item.hint ? (
|
|
||||||
typeof item.hint === 'string' ? (
|
|
||||||
<TruncatedText className="text-xs text-muted-foreground">· {item.hint}</TruncatedText>
|
|
||||||
) : (
|
|
||||||
<span className="truncate text-xs text-muted-foreground">· {item.hint}</span>
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
)
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={typeof item.label === 'string' ? item.label : idx}
|
|
||||||
size="sm"
|
|
||||||
className={cn(
|
|
||||||
'gap-0',
|
|
||||||
VARIANT_CLASS[item.variant ?? 'default'],
|
|
||||||
item.active && 'border-primary ring-1 ring-primary/30',
|
|
||||||
clickable && 'cursor-pointer transition-colors hover:bg-muted/40',
|
|
||||||
)}
|
|
||||||
onClick={item.onClick}
|
|
||||||
role={clickable ? 'button' : undefined}
|
|
||||||
tabIndex={clickable ? 0 : undefined}
|
|
||||||
onKeyDown={
|
|
||||||
clickable
|
|
||||||
? (e) => {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault()
|
|
||||||
item.onClick?.()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</Card>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import { Moon, Sun, SunMoon } from 'lucide-react'
|
||||||
|
import { useTheme } from 'next-themes'
|
||||||
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row'
|
||||||
|
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
|
||||||
|
import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import {
|
||||||
|
ToggleGroup,
|
||||||
|
ToggleGroupItem,
|
||||||
|
} from '@evobgp/ui/components/toggle-group'
|
||||||
|
import { Switch } from '@evobgp/ui/components/switch'
|
||||||
|
import { apiMutate } from '@/lib/api-client'
|
||||||
|
import { settingsKeys, settingsQueryOptions } from '@/queries/settings'
|
||||||
|
|
||||||
|
const THEME_OPTIONS = [
|
||||||
|
{ value: 'light', label: 'Светлая', icon: Sun },
|
||||||
|
{ value: 'dark', label: 'Тёмная', icon: Moon },
|
||||||
|
{ value: 'system', label: 'Система', icon: SunMoon },
|
||||||
|
] as const
|
||||||
|
|
||||||
|
function parseShowQuickActions(value: unknown): boolean {
|
||||||
|
if (value === false || value === 0 || value === 'false' || value === '0') return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AppearanceSettingsTab() {
|
||||||
|
const { theme, setTheme } = useTheme()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const settingsQ = useQuery(settingsQueryOptions())
|
||||||
|
const showQuickActions = parseShowQuickActions(settingsQ.data?.ui_show_quick_actions)
|
||||||
|
|
||||||
|
const patchMut = useMutation({
|
||||||
|
mutationFn: (payload: Record<string, boolean>) =>
|
||||||
|
apiMutate('/v1/settings', 'PATCH', payload),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success('Настройки интерфейса сохранены')
|
||||||
|
void qc.invalidateQueries({ queryKey: settingsKeys.all })
|
||||||
|
},
|
||||||
|
onError: (e) =>
|
||||||
|
toast.error(e instanceof Error ? e.message : 'Не удалось сохранить'),
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SettingsCard
|
||||||
|
title="Тема интерфейса"
|
||||||
|
description="Быстрый переключатель также доступен в боковой панели"
|
||||||
|
>
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="Тема"
|
||||||
|
description="Цветовая схема всех экранов в этом браузере."
|
||||||
|
>
|
||||||
|
<SettingRow
|
||||||
|
title="Тема"
|
||||||
|
description="Влияет на цветовую схему всех экранов в этом браузере."
|
||||||
|
titleAddon={
|
||||||
|
<Badge variant="primary-light" size="sm">
|
||||||
|
мгновенно
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<ToggleGroup
|
||||||
|
multiple={false}
|
||||||
|
value={[theme ?? 'system']}
|
||||||
|
onValueChange={(value) => {
|
||||||
|
if (value.length > 0) setTheme(value[0])
|
||||||
|
}}
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
aria-label="Тема интерфейса"
|
||||||
|
className="w-full sm:w-auto"
|
||||||
|
>
|
||||||
|
{THEME_OPTIONS.map((option) => {
|
||||||
|
const Icon = option.icon
|
||||||
|
return (
|
||||||
|
<ToggleGroupItem key={option.value} value={option.value} className="gap-1.5">
|
||||||
|
<Icon aria-hidden className="size-3.5" />
|
||||||
|
{option.label}
|
||||||
|
</ToggleGroupItem>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</ToggleGroup>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsCard
|
||||||
|
title="Дашборд"
|
||||||
|
description="Блоки на экране «Обзор»"
|
||||||
|
>
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="Быстрые действия"
|
||||||
|
description="Показывать KPI-like плитки быстрых переходов под метриками."
|
||||||
|
>
|
||||||
|
<SettingRow
|
||||||
|
title="Быстрые действия"
|
||||||
|
description="Блок с частыми переходами (модули, сеть, деплой) на дашборде."
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<Switch
|
||||||
|
checked={showQuickActions}
|
||||||
|
disabled={settingsQ.isLoading || patchMut.isPending}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
patchMut.mutate({ ui_show_quick_actions: checked })
|
||||||
|
}
|
||||||
|
aria-label="Показывать быстрые действия"
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
</SettingsCard>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
import { Link, useNavigate } from '@tanstack/react-router'
|
||||||
|
import { useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { Save } from 'lucide-react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { toast } from 'sonner'
|
||||||
|
|
||||||
|
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row'
|
||||||
|
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
|
||||||
|
import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { LoadingButton } from '@/components/loading-button'
|
||||||
|
import {
|
||||||
|
DEV_API_TOKEN,
|
||||||
|
normalizeApiToken,
|
||||||
|
setToken,
|
||||||
|
TOKEN_STORAGE_KEY,
|
||||||
|
} from '@/lib/api-client'
|
||||||
|
import { authKeys } from '@/queries/auth'
|
||||||
|
import { Alert, AlertDescription } from '@evobgp/ui/components/alert'
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { Input } from '@evobgp/ui/components/input'
|
||||||
|
|
||||||
|
export function ConnectionSettingsTab({ tokenRequired }: { tokenRequired: boolean }) {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const qc = useQueryClient()
|
||||||
|
const [token, setTokenValue] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = window.localStorage.getItem(TOKEN_STORAGE_KEY) ?? ''
|
||||||
|
setTokenValue(stored)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function applyToken(raw: string) {
|
||||||
|
const normalized = normalizeApiToken(raw)
|
||||||
|
setToken(normalized || null)
|
||||||
|
setTokenValue(normalized)
|
||||||
|
await qc.invalidateQueries({ queryKey: authKeys.all })
|
||||||
|
toast.success('Токен сохранён')
|
||||||
|
if (normalized && tokenRequired) {
|
||||||
|
void navigate({ to: '/dashboard' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveToken() {
|
||||||
|
void applyToken(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
function useDevToken() {
|
||||||
|
void applyToken(DEV_API_TOKEN)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{tokenRequired ? (
|
||||||
|
<Alert>
|
||||||
|
<AlertDescription>
|
||||||
|
Для доступа к разделу нужен API-токен. Сохраните токен ниже или нажмите «Использовать
|
||||||
|
dev».
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<SettingsCard
|
||||||
|
title="Подключение к API"
|
||||||
|
description="Токен хранится только в этом браузере (localStorage)"
|
||||||
|
footer={
|
||||||
|
<div className="flex w-full flex-wrap justify-end gap-2">
|
||||||
|
<Button type="button" variant="outline" onClick={useDevToken}>
|
||||||
|
Использовать dev
|
||||||
|
</Button>
|
||||||
|
<LoadingButton onClick={saveToken}>
|
||||||
|
<Save />
|
||||||
|
Сохранить токен
|
||||||
|
</LoadingButton>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="API-токен"
|
||||||
|
description="Параметры подключения браузера к EvoBGP API."
|
||||||
|
>
|
||||||
|
<SettingRow
|
||||||
|
title="Токен для запросов"
|
||||||
|
description={
|
||||||
|
<>
|
||||||
|
Ключ для заголовка <code className="text-xs">Authorization</code>. Управление
|
||||||
|
ключами tenant — в разделе{' '}
|
||||||
|
<Link to="/access" className="text-primary underline-offset-4 hover:underline">
|
||||||
|
Права доступа
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
titleAddon={
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
localStorage
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
labelFor="settings-token"
|
||||||
|
last
|
||||||
|
>
|
||||||
|
<Input
|
||||||
|
id="settings-token"
|
||||||
|
type="password"
|
||||||
|
autoComplete="off"
|
||||||
|
value={token}
|
||||||
|
onChange={(e) => setTokenValue(e.target.value)}
|
||||||
|
placeholder="dev или API-ключ"
|
||||||
|
/>
|
||||||
|
</SettingRow>
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
</SettingsCard>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { Link } from '@tanstack/react-router'
|
||||||
|
import {
|
||||||
|
ActivityIcon,
|
||||||
|
KeyRoundIcon,
|
||||||
|
SlidersHorizontalIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { Fragment } from 'react'
|
||||||
|
|
||||||
|
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemActions,
|
||||||
|
ItemContent,
|
||||||
|
ItemDescription,
|
||||||
|
ItemGroup,
|
||||||
|
ItemMedia,
|
||||||
|
ItemSeparator,
|
||||||
|
ItemTitle,
|
||||||
|
} from '@evobgp/ui/components/item'
|
||||||
|
|
||||||
|
const ADMIN_SECTIONS = [
|
||||||
|
{
|
||||||
|
id: 'tenant-settings',
|
||||||
|
to: '/tenant-settings' as const,
|
||||||
|
title: 'Параметры арендатора',
|
||||||
|
description: 'BIRD, ревизии, файловые логи и дополнительные ключи /v1/settings.',
|
||||||
|
icon: <SlidersHorizontalIcon aria-hidden="true" />,
|
||||||
|
badge: { label: 'operator', variant: 'warning-light' as const },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'access',
|
||||||
|
to: '/access' as const,
|
||||||
|
title: 'Права доступа',
|
||||||
|
description: 'API-ключи tenant, роли и управление доступом.',
|
||||||
|
icon: <KeyRoundIcon aria-hidden="true" />,
|
||||||
|
badge: { label: 'operator', variant: 'warning-light' as const },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'monitoring',
|
||||||
|
to: '/monitoring' as const,
|
||||||
|
title: 'Мониторинг',
|
||||||
|
description: 'Метрики, состояние jobs и observability control plane.',
|
||||||
|
icon: <ActivityIcon aria-hidden="true" />,
|
||||||
|
badge: { label: 'viewer+', variant: 'info-light' as const },
|
||||||
|
},
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export function SectionsSettingsTab() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SettingsCard
|
||||||
|
title="Разделы control plane"
|
||||||
|
description="Параметры tenant и операции — отдельно от настроек браузера"
|
||||||
|
>
|
||||||
|
<ItemGroup className="gap-0">
|
||||||
|
{ADMIN_SECTIONS.map((section, index) => (
|
||||||
|
<Fragment key={section.id}>
|
||||||
|
{index > 0 ? <ItemSeparator className="my-0" /> : null}
|
||||||
|
<Item className="min-h-0 items-center gap-4 px-5 py-3.5">
|
||||||
|
<ItemMedia className="self-center!">
|
||||||
|
<Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60">
|
||||||
|
{section.icon}
|
||||||
|
</Item>
|
||||||
|
</ItemMedia>
|
||||||
|
|
||||||
|
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
|
||||||
|
<ItemTitle className="gap-2 leading-5">
|
||||||
|
{section.title}
|
||||||
|
<Badge variant={section.badge.variant} size="xs">
|
||||||
|
{section.badge.label}
|
||||||
|
</Badge>
|
||||||
|
</ItemTitle>
|
||||||
|
<ItemDescription className="leading-5">{section.description}</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
|
||||||
|
<ItemActions className="shrink-0 justify-end self-center">
|
||||||
|
<Button variant="outline" size="sm" render={<Link to={section.to} />}>
|
||||||
|
Открыть
|
||||||
|
</Button>
|
||||||
|
</ItemActions>
|
||||||
|
</Item>
|
||||||
|
</Fragment>
|
||||||
|
))}
|
||||||
|
</ItemGroup>
|
||||||
|
</SettingsCard>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { MonitorIcon } from 'lucide-react'
|
||||||
|
|
||||||
|
import { SettingRow } from '@/components/blocks/settings-7/components/setting-row'
|
||||||
|
import { SettingsCard } from '@/components/blocks/settings-7/components/settings-card'
|
||||||
|
import { SettingsFieldGroup } from '@/components/blocks/settings-7/components/settings-field-group'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
import { QueryState } from '@/components/query-state'
|
||||||
|
import { TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
||||||
|
import { authSessionQueryOptions } from '@/queries/auth'
|
||||||
|
import {
|
||||||
|
Item,
|
||||||
|
ItemContent,
|
||||||
|
ItemDescription,
|
||||||
|
ItemGroup,
|
||||||
|
ItemMedia,
|
||||||
|
ItemTitle,
|
||||||
|
} from '@evobgp/ui/components/item'
|
||||||
|
|
||||||
|
const ROLE_LABELS: Record<string, string> = {
|
||||||
|
viewer: 'Просмотр',
|
||||||
|
editor: 'Редактор',
|
||||||
|
operator: 'Оператор',
|
||||||
|
node: 'Нода',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SessionSettingsTab() {
|
||||||
|
const hasStoredToken = Boolean(
|
||||||
|
typeof window !== 'undefined' && window.localStorage.getItem(TOKEN_STORAGE_KEY)?.trim(),
|
||||||
|
)
|
||||||
|
|
||||||
|
const sessionQ = useQuery({
|
||||||
|
...authSessionQueryOptions(),
|
||||||
|
enabled: hasStoredToken,
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<SettingsCard title="Текущая сессия" description="Проверка токена через GET /v1/auth/session">
|
||||||
|
<QueryState
|
||||||
|
data={sessionQ.data}
|
||||||
|
isLoading={sessionQ.isLoading && hasStoredToken}
|
||||||
|
isError={sessionQ.isError}
|
||||||
|
error={sessionQ.error}
|
||||||
|
empty={!hasStoredToken}
|
||||||
|
emptyTitle="Токен не задан"
|
||||||
|
emptyDescription="Сохраните API-токен во вкладке «Подключение»."
|
||||||
|
skeleton={<div className="h-24 px-5 py-4" />}
|
||||||
|
onRetry={() => sessionQ.refetch()}
|
||||||
|
>
|
||||||
|
{(session) => (
|
||||||
|
<ItemGroup className="gap-0">
|
||||||
|
<Item className="min-h-0 items-center gap-4 px-5 py-3.5">
|
||||||
|
<ItemMedia className="self-center!">
|
||||||
|
<Item className="bg-muted/60 border-background flex size-8 shrink-0 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] [&_svg]:size-4 [&_svg]:opacity-60">
|
||||||
|
<MonitorIcon aria-hidden="true" />
|
||||||
|
</Item>
|
||||||
|
</ItemMedia>
|
||||||
|
|
||||||
|
<ItemContent className="min-w-0 justify-center gap-0.5 self-center">
|
||||||
|
<ItemTitle className="gap-2 leading-5">
|
||||||
|
Этот браузер
|
||||||
|
<Badge variant="success-light" size="xs">
|
||||||
|
Активна
|
||||||
|
</Badge>
|
||||||
|
</ItemTitle>
|
||||||
|
<ItemDescription className="leading-5">
|
||||||
|
Tenant:{' '}
|
||||||
|
<code className="text-foreground font-mono text-xs">{session.tenant_id}</code>
|
||||||
|
</ItemDescription>
|
||||||
|
</ItemContent>
|
||||||
|
</Item>
|
||||||
|
</ItemGroup>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</SettingsCard>
|
||||||
|
|
||||||
|
<SettingsCard title="Роль и доступ" description="Права текущего API-ключа">
|
||||||
|
<SettingsFieldGroup
|
||||||
|
legend="Роль"
|
||||||
|
description="Уровень доступа, определённый API-ключом."
|
||||||
|
>
|
||||||
|
<SettingRow
|
||||||
|
title="Роль"
|
||||||
|
description="Определяет доступ к операциям control plane и CRUD."
|
||||||
|
titleAddon={
|
||||||
|
sessionQ.data ? (
|
||||||
|
<Badge variant="info-light" size="sm">
|
||||||
|
{ROLE_LABELS[sessionQ.data.role] ?? sessionQ.data.role}
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
неизвестно
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
last
|
||||||
|
>
|
||||||
|
{sessionQ.data ? (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Ключ с ролью <strong>{sessionQ.data.role}</strong> в tenant{' '}
|
||||||
|
<code className="font-mono text-xs">{sessionQ.data.tenant_id}</code>.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Сохраните токен и дождитесь проверки сессии.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</SettingRow>
|
||||||
|
</SettingsFieldGroup>
|
||||||
|
</SettingsCard>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
||||||
|
|
||||||
|
import { AppearanceSettingsTab } from './appearance-settings-tab'
|
||||||
|
import { ConnectionSettingsTab } from './connection-settings-tab'
|
||||||
|
import { SectionsSettingsTab } from './sections-settings-tab'
|
||||||
|
import { SessionSettingsTab } from './session-settings-tab'
|
||||||
|
import {
|
||||||
|
SETTINGS_TAB_ITEMS,
|
||||||
|
type SettingsTab,
|
||||||
|
} from './settings-tabs-data'
|
||||||
|
|
||||||
|
export function SettingsPageShell({
|
||||||
|
activeTab,
|
||||||
|
onTabChange,
|
||||||
|
tokenRequired,
|
||||||
|
}: {
|
||||||
|
activeTab: SettingsTab
|
||||||
|
onTabChange: (tab: SettingsTab) => void
|
||||||
|
tokenRequired: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<BadgeTabs
|
||||||
|
value={activeTab}
|
||||||
|
onValueChange={(value) => onTabChange(value as SettingsTab)}
|
||||||
|
items={SETTINGS_TAB_ITEMS.map((tab) => ({
|
||||||
|
value: tab.value,
|
||||||
|
label: tab.label,
|
||||||
|
icon: tab.icon,
|
||||||
|
}))}
|
||||||
|
>
|
||||||
|
<TabsContent value="connection" className="mt-0">
|
||||||
|
<ConnectionSettingsTab tokenRequired={tokenRequired} />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="session" className="mt-0">
|
||||||
|
<SessionSettingsTab />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="appearance" className="mt-0">
|
||||||
|
<AppearanceSettingsTab />
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="sections" className="mt-0">
|
||||||
|
<SectionsSettingsTab />
|
||||||
|
</TabsContent>
|
||||||
|
</BadgeTabs>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import {
|
||||||
|
KeyRoundIcon,
|
||||||
|
MonitorSmartphoneIcon,
|
||||||
|
PaletteIcon,
|
||||||
|
SlidersHorizontalIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { type ReactNode } from 'react'
|
||||||
|
|
||||||
|
export type SettingsTab = 'connection' | 'session' | 'appearance' | 'sections'
|
||||||
|
|
||||||
|
export type SettingsTabItem = {
|
||||||
|
value: SettingsTab
|
||||||
|
label: string
|
||||||
|
icon: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SETTINGS_TAB_ITEMS: SettingsTabItem[] = [
|
||||||
|
{
|
||||||
|
value: 'connection',
|
||||||
|
label: 'Подключение',
|
||||||
|
icon: <KeyRoundIcon aria-hidden="true" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'session',
|
||||||
|
label: 'Сессия',
|
||||||
|
icon: <MonitorSmartphoneIcon aria-hidden="true" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'appearance',
|
||||||
|
label: 'Оформление',
|
||||||
|
icon: <PaletteIcon aria-hidden="true" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'sections',
|
||||||
|
label: 'Разделы',
|
||||||
|
icon: <SlidersHorizontalIcon aria-hidden="true" />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export function parseSettingsTab(value: unknown): SettingsTab {
|
||||||
|
if (
|
||||||
|
value === 'session' ||
|
||||||
|
value === 'appearance' ||
|
||||||
|
value === 'sections'
|
||||||
|
) {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return 'connection'
|
||||||
|
}
|
||||||
@@ -1,35 +1,37 @@
|
|||||||
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||||
|
|
||||||
|
import { KpiStatGrid } from '@/components/kpi-stat-grid'
|
||||||
import { panelCardInsetClassName } from '@/components/panel-card'
|
import { panelCardInsetClassName } from '@/components/panel-card'
|
||||||
import {
|
import {
|
||||||
chartPanelGridClassName,
|
chartPanelGridClassName,
|
||||||
dashboardKpiGridClassName,
|
|
||||||
dashboardMainSidebarClassName,
|
dashboardMainSidebarClassName,
|
||||||
kpiGridClassName,
|
|
||||||
} from '@/lib/ui-surface'
|
} from '@/lib/ui-surface'
|
||||||
import { SectionCards } from './section-cards'
|
|
||||||
|
|
||||||
export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
|
export function KpiStatGridSkeleton({ count = 4 }: { count?: number }) {
|
||||||
return (
|
return (
|
||||||
<SectionCards
|
<KpiStatGrid
|
||||||
items={Array.from({ length: count }, (_, i) => ({
|
items={Array.from({ length: count }, (_, index) => ({
|
||||||
icon: <Skeleton className="size-4 rounded-sm" key={`icon-${i}`} />,
|
id: `kpi-skeleton-${index}`,
|
||||||
label: <Skeleton className="h-3 w-20" key={`label-${i}`} />,
|
icon: <Skeleton className="size-4 rounded-sm" />,
|
||||||
value: <Skeleton className="h-5 w-16" key={`value-${i}`} />,
|
value: <Skeleton className="h-8 w-16" />,
|
||||||
|
label: <Skeleton className="h-4 w-28" />,
|
||||||
|
footer: <Skeleton className="h-5 w-32 rounded-full" />,
|
||||||
}))}
|
}))}
|
||||||
|
aria-label="Загрузка показателей"
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Matches dashboard-5 / telemetry layout: KPI row, charts, main+sidebar, bottom grids. */
|
/** @deprecated Use KpiStatGridSkeleton */
|
||||||
|
export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
|
||||||
|
return <KpiStatGridSkeleton count={count} />
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Matches dashboard layout: KPI row, charts, main+sidebar, bottom grids. */
|
||||||
export function AnalyticsDashboardSkeleton() {
|
export function AnalyticsDashboardSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="@container flex flex-col gap-4">
|
<div className="@container flex flex-col gap-4">
|
||||||
<div className={dashboardKpiGridClassName}>
|
<KpiStatGridSkeleton count={6} />
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
|
||||||
<Skeleton key={`kpi-${i}`} className="h-28 w-full rounded-xl" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={chartPanelGridClassName}>
|
<div className={chartPanelGridClassName}>
|
||||||
<Skeleton className="h-72 w-full rounded-xl" />
|
<Skeleton className="h-72 w-full rounded-xl" />
|
||||||
@@ -50,13 +52,7 @@ export function AnalyticsDashboardSkeleton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function KpiSparklineSkeleton({ count = 4 }: { count?: number }) {
|
export function KpiSparklineSkeleton({ count = 4 }: { count?: number }) {
|
||||||
return (
|
return <KpiStatGridSkeleton count={count} />
|
||||||
<div className={kpiGridClassName}>
|
|
||||||
{Array.from({ length: count }).map((_, i) => (
|
|
||||||
<Skeleton key={i} className="h-36 w-full rounded-xl" />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
|
export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import {
|
||||||
|
DEFAULT_APP_SWITCHER_CONFIG,
|
||||||
|
getAppUrl as getAppUrlFromConfig,
|
||||||
|
type AppSwitcherConfig,
|
||||||
|
} from '@/lib/app-switcher-config'
|
||||||
|
import { appSwitcherQueryOptions } from '@/queries/app-switcher'
|
||||||
|
import { getClaims, isAuthEnabled } from '@/lib/auth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Portal-first switcher: tries `/api/v1/app-switcher` and filters entries by
|
||||||
|
* the current JWT `apps` claim; falls back to `VITE_APP_SWITCHER`/defaults
|
||||||
|
* when auth-portal is disabled or unavailable.
|
||||||
|
*/
|
||||||
|
export function useAppSwitcherConfig(): {
|
||||||
|
config: AppSwitcherConfig
|
||||||
|
isLoading: boolean
|
||||||
|
} {
|
||||||
|
const authOn = isAuthEnabled()
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
...appSwitcherQueryOptions(),
|
||||||
|
enabled: authOn,
|
||||||
|
})
|
||||||
|
const claims = getClaims()
|
||||||
|
|
||||||
|
const config = useMemo<AppSwitcherConfig>(() => {
|
||||||
|
const raw = data ?? DEFAULT_APP_SWITCHER_CONFIG
|
||||||
|
if (!authOn) return raw
|
||||||
|
const allowed = claims?.apps
|
||||||
|
if (!allowed?.length) return raw
|
||||||
|
const set = new Set(allowed)
|
||||||
|
const apps = raw.apps.filter((a) => set.has(a.id))
|
||||||
|
if (!apps.length) return raw
|
||||||
|
return { ...raw, apps }
|
||||||
|
}, [data, authOn, claims?.apps])
|
||||||
|
|
||||||
|
return { config, isLoading: authOn && isLoading }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAppUrl(appId: string): string | undefined {
|
||||||
|
const { config } = useAppSwitcherConfig()
|
||||||
|
return getAppUrlFromConfig(appId, config ?? DEFAULT_APP_SWITCHER_CONFIG)
|
||||||
|
}
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
import type React from "react"
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
type ChangeEvent,
|
||||||
|
type DragEvent,
|
||||||
|
type InputHTMLAttributes,
|
||||||
|
} from "react"
|
||||||
|
|
||||||
|
export type FileMetadata = {
|
||||||
|
name: string
|
||||||
|
size: number
|
||||||
|
type: string
|
||||||
|
url: string
|
||||||
|
id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileWithPreview = {
|
||||||
|
file: File | FileMetadata
|
||||||
|
id: string
|
||||||
|
preview?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileUploadOptions = {
|
||||||
|
maxFiles?: number // Only used when multiple is true, defaults to Infinity
|
||||||
|
maxSize?: number // in bytes
|
||||||
|
accept?: string
|
||||||
|
multiple?: boolean // Defaults to false
|
||||||
|
initialFiles?: FileMetadata[]
|
||||||
|
onFilesChange?: (files: FileWithPreview[]) => void // Callback when files change
|
||||||
|
onFilesAdded?: (addedFiles: FileWithPreview[]) => void // Callback when new files are added
|
||||||
|
onError?: (errors: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileUploadState = {
|
||||||
|
files: FileWithPreview[]
|
||||||
|
isDragging: boolean
|
||||||
|
errors: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type FileUploadActions = {
|
||||||
|
addFiles: (files: FileList | File[]) => void
|
||||||
|
removeFile: (id: string) => void
|
||||||
|
clearFiles: () => void
|
||||||
|
clearErrors: () => void
|
||||||
|
handleDragEnter: (e: DragEvent<HTMLElement>) => void
|
||||||
|
handleDragLeave: (e: DragEvent<HTMLElement>) => void
|
||||||
|
handleDragOver: (e: DragEvent<HTMLElement>) => void
|
||||||
|
handleDrop: (e: DragEvent<HTMLElement>) => void
|
||||||
|
handleFileChange: (e: ChangeEvent<HTMLInputElement>) => void
|
||||||
|
openFileDialog: () => void
|
||||||
|
getInputProps: (
|
||||||
|
props?: InputHTMLAttributes<HTMLInputElement>
|
||||||
|
) => InputHTMLAttributes<HTMLInputElement> & {
|
||||||
|
ref: React.Ref<HTMLInputElement>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFileUpload = (
|
||||||
|
options: FileUploadOptions = {}
|
||||||
|
): [FileUploadState, FileUploadActions] => {
|
||||||
|
const {
|
||||||
|
maxFiles = Number.POSITIVE_INFINITY,
|
||||||
|
maxSize = Number.POSITIVE_INFINITY,
|
||||||
|
accept = "*",
|
||||||
|
multiple = false,
|
||||||
|
initialFiles = [],
|
||||||
|
onFilesChange,
|
||||||
|
onFilesAdded,
|
||||||
|
onError,
|
||||||
|
} = options
|
||||||
|
|
||||||
|
const [state, setState] = useState<FileUploadState>({
|
||||||
|
files: initialFiles.map((file) => ({
|
||||||
|
file,
|
||||||
|
id: file.id,
|
||||||
|
preview: file.url,
|
||||||
|
})),
|
||||||
|
isDragging: false,
|
||||||
|
errors: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
|
const validateFile = useCallback(
|
||||||
|
(file: File | FileMetadata): string | null => {
|
||||||
|
if (file instanceof File) {
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
return `File "${file.name}" exceeds the maximum size of ${formatBytes(maxSize)}.`
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
return `File "${file.name}" exceeds the maximum size of ${formatBytes(maxSize)}.`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (accept !== "*") {
|
||||||
|
const acceptedTypes = accept.split(",").map((type) => type.trim())
|
||||||
|
const fileType = file instanceof File ? file.type || "" : file.type
|
||||||
|
const fileExtension = `.${file instanceof File ? file.name.split(".").pop() : file.name.split(".").pop()}`
|
||||||
|
|
||||||
|
const isAccepted = acceptedTypes.some((type) => {
|
||||||
|
if (type.startsWith(".")) {
|
||||||
|
return fileExtension.toLowerCase() === type.toLowerCase()
|
||||||
|
}
|
||||||
|
if (type.endsWith("/*")) {
|
||||||
|
const baseType = type.split("/")[0]
|
||||||
|
return fileType.startsWith(`${baseType}/`)
|
||||||
|
}
|
||||||
|
return fileType === type
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!isAccepted) {
|
||||||
|
return `File "${file instanceof File ? file.name : file.name}" is not an accepted file type.`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
},
|
||||||
|
[accept, maxSize]
|
||||||
|
)
|
||||||
|
|
||||||
|
const createPreview = useCallback(
|
||||||
|
(file: File | FileMetadata): string | undefined => {
|
||||||
|
if (file instanceof File) {
|
||||||
|
return URL.createObjectURL(file)
|
||||||
|
}
|
||||||
|
return file.url
|
||||||
|
},
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
|
const generateUniqueId = useCallback((file: File | FileMetadata): string => {
|
||||||
|
if (file instanceof File) {
|
||||||
|
return `${file.name}-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`
|
||||||
|
}
|
||||||
|
return file.id
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearFiles = useCallback(() => {
|
||||||
|
setState((prev) => {
|
||||||
|
// Clean up object URLs
|
||||||
|
for (const file of prev.files) {
|
||||||
|
if (
|
||||||
|
file.preview &&
|
||||||
|
file.file instanceof File &&
|
||||||
|
file.file.type.startsWith("image/")
|
||||||
|
) {
|
||||||
|
URL.revokeObjectURL(file.preview)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.value = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
const newState = {
|
||||||
|
...prev,
|
||||||
|
files: [],
|
||||||
|
errors: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
onFilesChange?.(newState.files)
|
||||||
|
return newState
|
||||||
|
})
|
||||||
|
}, [onFilesChange])
|
||||||
|
|
||||||
|
const addFiles = useCallback(
|
||||||
|
(newFiles: FileList | File[]) => {
|
||||||
|
if (!newFiles || newFiles.length === 0) return
|
||||||
|
|
||||||
|
const newFilesArray = Array.from(newFiles)
|
||||||
|
const errors: string[] = []
|
||||||
|
|
||||||
|
// Clear existing errors when new files are uploaded
|
||||||
|
setState((prev) => ({ ...prev, errors: [] }))
|
||||||
|
|
||||||
|
// In single file mode, clear existing files first
|
||||||
|
if (!multiple) {
|
||||||
|
clearFiles()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if adding these files would exceed maxFiles (only in multiple mode)
|
||||||
|
if (
|
||||||
|
multiple &&
|
||||||
|
maxFiles !== Number.POSITIVE_INFINITY &&
|
||||||
|
state.files.length + newFilesArray.length > maxFiles
|
||||||
|
) {
|
||||||
|
errors.push(`You can only upload a maximum of ${maxFiles} files.`)
|
||||||
|
onError?.(errors)
|
||||||
|
setState((prev) => ({ ...prev, errors }))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const validFiles: FileWithPreview[] = []
|
||||||
|
|
||||||
|
for (const file of newFilesArray) {
|
||||||
|
// Only check for duplicates if multiple files are allowed
|
||||||
|
if (multiple) {
|
||||||
|
const isDuplicate = state.files.some(
|
||||||
|
(existingFile) =>
|
||||||
|
existingFile.file.name === file.name &&
|
||||||
|
existingFile.file.size === file.size
|
||||||
|
)
|
||||||
|
|
||||||
|
// Skip duplicate files silently
|
||||||
|
if (isDuplicate) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check file size
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
errors.push(
|
||||||
|
multiple
|
||||||
|
? `Some files exceed the maximum size of ${formatBytes(maxSize)}.`
|
||||||
|
: `File exceeds the maximum size of ${formatBytes(maxSize)}.`
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = validateFile(file)
|
||||||
|
if (error) {
|
||||||
|
errors.push(error)
|
||||||
|
} else {
|
||||||
|
validFiles.push({
|
||||||
|
file,
|
||||||
|
id: generateUniqueId(file),
|
||||||
|
preview: createPreview(file),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only update state if we have valid files to add
|
||||||
|
if (validFiles.length > 0) {
|
||||||
|
// Call the onFilesAdded callback with the newly added valid files
|
||||||
|
onFilesAdded?.(validFiles)
|
||||||
|
|
||||||
|
setState((prev) => {
|
||||||
|
const newFiles = !multiple
|
||||||
|
? validFiles
|
||||||
|
: [...prev.files, ...validFiles]
|
||||||
|
onFilesChange?.(newFiles)
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
files: newFiles,
|
||||||
|
errors,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if (errors.length > 0) {
|
||||||
|
onError?.(errors)
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
errors,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset input value after handling files
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.value = ""
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
state.files,
|
||||||
|
maxFiles,
|
||||||
|
multiple,
|
||||||
|
maxSize,
|
||||||
|
validateFile,
|
||||||
|
createPreview,
|
||||||
|
generateUniqueId,
|
||||||
|
clearFiles,
|
||||||
|
onFilesChange,
|
||||||
|
onFilesAdded,
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
const removeFile = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
setState((prev) => {
|
||||||
|
const fileToRemove = prev.files.find((file) => file.id === id)
|
||||||
|
if (
|
||||||
|
fileToRemove &&
|
||||||
|
fileToRemove.preview &&
|
||||||
|
fileToRemove.file instanceof File &&
|
||||||
|
fileToRemove.file.type.startsWith("image/")
|
||||||
|
) {
|
||||||
|
URL.revokeObjectURL(fileToRemove.preview)
|
||||||
|
}
|
||||||
|
|
||||||
|
const newFiles = prev.files.filter((file) => file.id !== id)
|
||||||
|
onFilesChange?.(newFiles)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
files: newFiles,
|
||||||
|
errors: [],
|
||||||
|
}
|
||||||
|
})
|
||||||
|
},
|
||||||
|
[onFilesChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
const clearErrors = useCallback(() => {
|
||||||
|
setState((prev) => ({
|
||||||
|
...prev,
|
||||||
|
errors: [],
|
||||||
|
}))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDragEnter = useCallback((e: DragEvent<HTMLElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setState((prev) => ({ ...prev, isDragging: true }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDragLeave = useCallback((e: DragEvent<HTMLElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
|
||||||
|
if (e.currentTarget.contains(e.relatedTarget as Node)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setState((prev) => ({ ...prev, isDragging: false }))
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDragOver = useCallback((e: DragEvent<HTMLElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(e: DragEvent<HTMLElement>) => {
|
||||||
|
e.preventDefault()
|
||||||
|
e.stopPropagation()
|
||||||
|
setState((prev) => ({ ...prev, isDragging: false }))
|
||||||
|
|
||||||
|
// Don't process files if the input is disabled
|
||||||
|
if (inputRef.current?.disabled) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||||
|
// In single file mode, only use the first file
|
||||||
|
if (!multiple) {
|
||||||
|
const file = e.dataTransfer.files[0]
|
||||||
|
addFiles([file])
|
||||||
|
} else {
|
||||||
|
addFiles(e.dataTransfer.files)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addFiles, multiple]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleFileChange = useCallback(
|
||||||
|
(e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
if (e.target.files && e.target.files.length > 0) {
|
||||||
|
addFiles(e.target.files)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[addFiles]
|
||||||
|
)
|
||||||
|
|
||||||
|
const openFileDialog = useCallback(() => {
|
||||||
|
if (inputRef.current) {
|
||||||
|
inputRef.current.click()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const getInputProps = useCallback(
|
||||||
|
(props: InputHTMLAttributes<HTMLInputElement> = {}) => {
|
||||||
|
return {
|
||||||
|
...props,
|
||||||
|
type: "file" as const,
|
||||||
|
onChange: handleFileChange,
|
||||||
|
accept: props.accept || accept,
|
||||||
|
multiple: props.multiple !== undefined ? props.multiple : multiple,
|
||||||
|
ref: inputRef,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[accept, multiple, handleFileChange]
|
||||||
|
)
|
||||||
|
|
||||||
|
return [
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
addFiles,
|
||||||
|
removeFile,
|
||||||
|
clearFiles,
|
||||||
|
clearErrors,
|
||||||
|
handleDragEnter,
|
||||||
|
handleDragLeave,
|
||||||
|
handleDragOver,
|
||||||
|
handleDrop,
|
||||||
|
handleFileChange,
|
||||||
|
openFileDialog,
|
||||||
|
getInputProps,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to format bytes to human-readable format
|
||||||
|
export const formatBytes = (bytes: number, decimals = 2): string => {
|
||||||
|
if (bytes === 0) return "0 Bytes"
|
||||||
|
|
||||||
|
const k = 1024
|
||||||
|
const dm = decimals < 0 ? 0 : decimals
|
||||||
|
const sizes = ["Bytes", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"]
|
||||||
|
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||||||
|
|
||||||
|
return Number.parseFloat((bytes / k ** i).toFixed(dm)) + sizes[i]
|
||||||
|
}
|
||||||
@@ -30,8 +30,19 @@ export function normalizeApiToken(raw: string): string {
|
|||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Portal JWT storage key mirrored from `@/lib/auth`. Kept local to avoid a
|
||||||
|
* cycle when `auth` starts pulling from `api-client` for the config endpoint. */
|
||||||
|
const PORTAL_TOKEN_STORAGE_KEY = 'evobgp_portal_token'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bearer selection: portal JWT wins over the legacy API-key. When auth-portal
|
||||||
|
* is disabled or hasn't issued a token yet we fall back to the local API-key
|
||||||
|
* (`evobgp_api_token`) so curl-style tooling keeps working.
|
||||||
|
*/
|
||||||
function getToken(): string | null {
|
function getToken(): string | null {
|
||||||
if (typeof window === 'undefined') return null
|
if (typeof window === 'undefined') return null
|
||||||
|
const portal = window.localStorage.getItem(PORTAL_TOKEN_STORAGE_KEY)
|
||||||
|
if (portal && portal.trim()) return portal.trim()
|
||||||
const raw = window.localStorage.getItem(TOKEN_STORAGE_KEY)
|
const raw = window.localStorage.getItem(TOKEN_STORAGE_KEY)
|
||||||
if (!raw) return null
|
if (!raw) return null
|
||||||
const normalized = normalizeApiToken(raw)
|
const normalized = normalizeApiToken(raw)
|
||||||
|
|||||||
@@ -0,0 +1,104 @@
|
|||||||
|
import {
|
||||||
|
ChartBarIcon,
|
||||||
|
CloudIcon,
|
||||||
|
GlobeIcon,
|
||||||
|
LayoutDashboardIcon,
|
||||||
|
ServerIcon,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
import { CURRENT_APP_ID } from '@/lib/auth'
|
||||||
|
|
||||||
|
export { CURRENT_APP_ID }
|
||||||
|
|
||||||
|
const appSwitcherIconSchema = z.enum(['server', 'cloud', 'globe', 'dashboard', 'chart'])
|
||||||
|
|
||||||
|
export type AppSwitcherIconName = z.infer<typeof appSwitcherIconSchema>
|
||||||
|
|
||||||
|
export const APP_SWITCHER_ICONS: Record<AppSwitcherIconName, LucideIcon> = {
|
||||||
|
server: ServerIcon,
|
||||||
|
cloud: CloudIcon,
|
||||||
|
globe: GlobeIcon,
|
||||||
|
dashboard: LayoutDashboardIcon,
|
||||||
|
chart: ChartBarIcon,
|
||||||
|
}
|
||||||
|
|
||||||
|
const appSwitcherEntrySchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
subtitle: z.string().optional(),
|
||||||
|
url: z.string(),
|
||||||
|
icon: appSwitcherIconSchema.default('server'),
|
||||||
|
shortcut: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
const appSwitcherConfigSchema = z.object({
|
||||||
|
menuLabel: z.string().default('Приложения'),
|
||||||
|
apps: z.array(appSwitcherEntrySchema).min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type AppSwitcherEntry = z.infer<typeof appSwitcherEntrySchema>
|
||||||
|
export type AppSwitcherConfig = z.infer<typeof appSwitcherConfigSchema>
|
||||||
|
|
||||||
|
/** Shared defaults across ops apps — chrome app switcher. */
|
||||||
|
export const DEFAULT_APP_SWITCHER_CONFIG: AppSwitcherConfig = {
|
||||||
|
menuLabel: 'Приложения',
|
||||||
|
apps: [
|
||||||
|
{
|
||||||
|
id: 'vps',
|
||||||
|
name: 'VPS Tracker',
|
||||||
|
subtitle: 'Учёт виртуальных серверов',
|
||||||
|
url: 'http://192.168.100.67:3001',
|
||||||
|
icon: 'server',
|
||||||
|
shortcut: '⌘1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'cfdm',
|
||||||
|
name: 'CF Domain Manager',
|
||||||
|
subtitle: 'Управление доменами',
|
||||||
|
url: 'http://192.168.100.67:6363',
|
||||||
|
icon: 'cloud',
|
||||||
|
shortcut: '⌘2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bgp',
|
||||||
|
name: 'EvoBGP',
|
||||||
|
subtitle: 'BGP маршрутизация',
|
||||||
|
url: 'http://192.168.100.67:3000',
|
||||||
|
icon: 'globe',
|
||||||
|
shortcut: '⌘3',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseAppSwitcherConfig(raw?: string): AppSwitcherConfig {
|
||||||
|
if (!raw?.trim()) {
|
||||||
|
return DEFAULT_APP_SWITCHER_CONFIG
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(raw) as unknown
|
||||||
|
return appSwitcherConfigSchema.parse(parsed)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Invalid VITE_APP_SWITCHER, using defaults:', error)
|
||||||
|
return DEFAULT_APP_SWITCHER_CONFIG
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAppSwitcherConfig(): AppSwitcherConfig {
|
||||||
|
return parseAppSwitcherConfig(import.meta.env.VITE_APP_SWITCHER)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAppUrl(
|
||||||
|
appId: string,
|
||||||
|
config: AppSwitcherConfig = getAppSwitcherConfig(),
|
||||||
|
): string | undefined {
|
||||||
|
return config.apps.find((app) => app.id === appId)?.url
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCurrentApp(
|
||||||
|
config: AppSwitcherConfig = getAppSwitcherConfig(),
|
||||||
|
): AppSwitcherEntry {
|
||||||
|
return config.apps.find((app) => app.id === CURRENT_APP_ID) ?? config.apps[0]!
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
/**
|
||||||
|
* Portal JWT SSO integration for EvoBGP UI.
|
||||||
|
*
|
||||||
|
* Two independent auth channels:
|
||||||
|
* - Portal JWT (this module) — SSO from auth-portal, used to gate UI + Bearer
|
||||||
|
* to EvoBGP API when the API accepts portal-issued tokens.
|
||||||
|
* - Local API key (see `@/lib/api-client`) — legacy `evobgp_api_token`
|
||||||
|
* in localStorage; used when auth-portal is disabled or as a fallback.
|
||||||
|
*
|
||||||
|
* `VITE_AUTH_ENABLED=false` → keep the API-key gate.
|
||||||
|
* `VITE_AUTH_ENABLED=true` → require portal JWT; API-key kept only for tools
|
||||||
|
* (curl/dev) and as backup.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TOKEN_KEY = 'evobgp_portal_token'
|
||||||
|
const HANDOFF_KEY = 'evobgp_portal_401_handoff'
|
||||||
|
const HANDOFF_AT_KEY = 'evobgp_portal_handoff_at'
|
||||||
|
/** Min gap between portal handoffs — breaks SSO↔401 redirect storms. */
|
||||||
|
const HANDOFF_COOLDOWN_MS = 12_000
|
||||||
|
|
||||||
|
/** EvoBGP is `bgp` in the auth-portal registry (see APP_IDS). */
|
||||||
|
export const CURRENT_APP_ID = 'bgp'
|
||||||
|
|
||||||
|
export type AccessClaims = {
|
||||||
|
sub: string
|
||||||
|
email: string
|
||||||
|
name: string
|
||||||
|
apps: string[]
|
||||||
|
permissions: string[]
|
||||||
|
is_admin?: boolean
|
||||||
|
iss?: string
|
||||||
|
exp?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RuntimeAuthConfig = {
|
||||||
|
required: boolean
|
||||||
|
portalUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
let runtimeConfig: RuntimeAuthConfig | null = null
|
||||||
|
let runtimeConfigPromise: Promise<RuntimeAuthConfig> | null = null
|
||||||
|
|
||||||
|
function viteAuthEnabled(): boolean {
|
||||||
|
return (
|
||||||
|
import.meta.env.VITE_AUTH_ENABLED === 'true' ||
|
||||||
|
import.meta.env.VITE_AUTH_ENABLED === '1'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function vitePortalUrl(): string {
|
||||||
|
return (import.meta.env.VITE_AUTH_PORTAL_URL ?? 'http://localhost:5175').replace(
|
||||||
|
/\/$/,
|
||||||
|
'',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load auth mode from EvoBGP API (Docker-friendly). Falls back to VITE_* flags
|
||||||
|
* when the endpoint isn't implemented (404) or the API is unreachable.
|
||||||
|
*
|
||||||
|
* Note: EvoBGP uses `/v1/...` (not `/api/v1/...`).
|
||||||
|
*/
|
||||||
|
export async function ensureAuthConfig(): Promise<RuntimeAuthConfig> {
|
||||||
|
if (runtimeConfig) return runtimeConfig
|
||||||
|
if (runtimeConfigPromise) return runtimeConfigPromise
|
||||||
|
|
||||||
|
runtimeConfigPromise = (async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/v1/auth/config', {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
const data = (await res.json()) as {
|
||||||
|
required?: boolean
|
||||||
|
portal_url?: string
|
||||||
|
}
|
||||||
|
runtimeConfig = {
|
||||||
|
required: Boolean(data.required) || viteAuthEnabled(),
|
||||||
|
portalUrl: (data.portal_url || vitePortalUrl()).replace(/\/$/, ''),
|
||||||
|
}
|
||||||
|
return runtimeConfig
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore — fall through to vite defaults */
|
||||||
|
}
|
||||||
|
runtimeConfig = {
|
||||||
|
required: viteAuthEnabled(),
|
||||||
|
portalUrl: vitePortalUrl(),
|
||||||
|
}
|
||||||
|
return runtimeConfig
|
||||||
|
})().finally(() => {
|
||||||
|
runtimeConfigPromise = null
|
||||||
|
})
|
||||||
|
|
||||||
|
return runtimeConfigPromise
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAuthConfigSync(): RuntimeAuthConfig | null {
|
||||||
|
return runtimeConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getPortalToken(): string | null {
|
||||||
|
if (typeof window === 'undefined') return null
|
||||||
|
return window.localStorage.getItem(TOKEN_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setPortalToken(token: string): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
window.localStorage.setItem(TOKEN_KEY, token)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearPortalToken(): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
window.localStorage.removeItem(TOKEN_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAuthEnabled(): boolean {
|
||||||
|
if (runtimeConfig) return runtimeConfig.required
|
||||||
|
return viteAuthEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function authPortalUrl(): string {
|
||||||
|
if (runtimeConfig?.portalUrl) return runtimeConfig.portalUrl
|
||||||
|
return vitePortalUrl()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPortalHandoffCoolingDown(): boolean {
|
||||||
|
if (typeof window === 'undefined') return false
|
||||||
|
const raw = window.sessionStorage.getItem(HANDOFF_AT_KEY)
|
||||||
|
if (!raw) return false
|
||||||
|
const at = Number(raw)
|
||||||
|
if (!Number.isFinite(at)) return false
|
||||||
|
return Date.now() - at < HANDOFF_COOLDOWN_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markPortalHandoff(): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
window.sessionStorage.setItem(HANDOFF_KEY, '1')
|
||||||
|
window.sessionStorage.setItem(HANDOFF_AT_KEY, String(Date.now()))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearPortalHandoffFlag(): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
window.sessionStorage.removeItem(HANDOFF_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear cooldown too — use on intentional logout so next login is allowed. */
|
||||||
|
export function resetPortalHandoff(): void {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
window.sessionStorage.removeItem(HANDOFF_KEY)
|
||||||
|
window.sessionStorage.removeItem(HANDOFF_AT_KEY)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPortalHandoffFlag(): boolean {
|
||||||
|
if (typeof window === 'undefined') return false
|
||||||
|
return window.sessionStorage.getItem(HANDOFF_KEY) === '1'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirect to auth-portal SSO. Returns false if cooldown blocks the handoff
|
||||||
|
* (clears local token) — prevents infinite SSO when API rejects JWT.
|
||||||
|
*/
|
||||||
|
export function redirectToPortalLogin(returnTo?: string): boolean {
|
||||||
|
if (typeof window === 'undefined') return false
|
||||||
|
if (isPortalHandoffCoolingDown()) {
|
||||||
|
clearPortalToken()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
markPortalHandoff()
|
||||||
|
const callback = returnTo ?? `${window.location.origin}/auth/callback`
|
||||||
|
const url = new URL(authPortalUrl())
|
||||||
|
url.searchParams.set('return_to', callback)
|
||||||
|
window.location.assign(url.toString())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** End portal SSO session (refresh cookie + portal token). */
|
||||||
|
export function redirectToPortalLogout(): void {
|
||||||
|
clearPortalToken()
|
||||||
|
resetPortalHandoff()
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
window.location.assign(`${authPortalUrl()}/logout`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseHashToken(hash: string): {
|
||||||
|
accessToken: string | null
|
||||||
|
expiresAt: string | null
|
||||||
|
} {
|
||||||
|
const raw = hash.startsWith('#') ? hash.slice(1) : hash
|
||||||
|
const params = new URLSearchParams(raw)
|
||||||
|
return {
|
||||||
|
accessToken: params.get('access_token'),
|
||||||
|
expiresAt: params.get('expires_at'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function decodeClaims(token: string): AccessClaims | null {
|
||||||
|
try {
|
||||||
|
const parts = token.split('.')
|
||||||
|
if (parts.length < 2) return null
|
||||||
|
const json = atob(parts[1]!.replace(/-/g, '+').replace(/_/g, '/'))
|
||||||
|
const payload = JSON.parse(json) as Record<string, unknown>
|
||||||
|
return {
|
||||||
|
sub: String(payload.sub ?? ''),
|
||||||
|
email: String(payload.email ?? ''),
|
||||||
|
name: String(payload.name ?? ''),
|
||||||
|
apps: Array.isArray(payload.apps) ? payload.apps.map(String) : [],
|
||||||
|
permissions: Array.isArray(payload.permissions)
|
||||||
|
? payload.permissions.map(String)
|
||||||
|
: [],
|
||||||
|
is_admin: Boolean(payload.is_admin),
|
||||||
|
iss: payload.iss ? String(payload.iss) : undefined,
|
||||||
|
exp: typeof payload.exp === 'number' ? payload.exp : undefined,
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getClaims(): AccessClaims | null {
|
||||||
|
const token = getPortalToken()
|
||||||
|
if (!token) return null
|
||||||
|
const claims = decodeClaims(token)
|
||||||
|
if (!claims) return null
|
||||||
|
if (claims.exp && claims.exp * 1000 < Date.now()) {
|
||||||
|
clearPortalToken()
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
return claims
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasPermission(
|
||||||
|
granted: readonly string[],
|
||||||
|
required: string,
|
||||||
|
): boolean {
|
||||||
|
if (granted.includes(required)) return true
|
||||||
|
const parts = required.split(':')
|
||||||
|
if (parts.length !== 3) return false
|
||||||
|
const [app, section, action] = parts
|
||||||
|
if (action === 'read') {
|
||||||
|
return (
|
||||||
|
granted.includes(`${app}:${section}:write`) ||
|
||||||
|
granted.includes(`${app}:${section}:admin`)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (action === 'write') {
|
||||||
|
return granted.includes(`${app}:${section}:admin`)
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Access-check: pass when portal auth is disabled or claim grants required. */
|
||||||
|
export function can(required: string): boolean {
|
||||||
|
if (!isAuthEnabled()) return true
|
||||||
|
const claims = getClaims()
|
||||||
|
if (!claims) return false
|
||||||
|
if (!claims.apps.includes(CURRENT_APP_ID)) return false
|
||||||
|
if (claims.is_admin) return true
|
||||||
|
return hasPermission(claims.permissions, required)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Nav path → minimum permission to show the item. Sync with app-shell NAV. */
|
||||||
|
export function permissionForPath(pathname: string): string | null {
|
||||||
|
if (pathname === '/' || pathname.startsWith('/dashboard')) {
|
||||||
|
return 'bgp:dashboard:read'
|
||||||
|
}
|
||||||
|
if (pathname.startsWith('/modules')) return 'bgp:modules:read'
|
||||||
|
if (pathname.startsWith('/lookup')) return 'bgp:lookup:read'
|
||||||
|
if (pathname.startsWith('/network')) return 'bgp:network:read'
|
||||||
|
if (pathname.startsWith('/directories')) return 'bgp:directories:read'
|
||||||
|
if (pathname.startsWith('/operations')) return 'bgp:operations:read'
|
||||||
|
if (pathname.startsWith('/schedule')) return 'bgp:schedule:read'
|
||||||
|
if (pathname.startsWith('/monitoring')) return 'bgp:monitoring:read'
|
||||||
|
if (pathname.startsWith('/access')) return 'bgp:access:admin'
|
||||||
|
if (pathname.startsWith('/tenant-settings')) return 'bgp:tenant_settings:admin'
|
||||||
|
if (pathname.startsWith('/settings')) return 'bgp:settings:read'
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const FALLBACK_PATH = '/dashboard'
|
||||||
|
|
||||||
|
/** First path in the sidebar the current user may open. */
|
||||||
|
export function firstAllowedPath(): string {
|
||||||
|
const candidates: readonly string[] = [
|
||||||
|
'/dashboard',
|
||||||
|
'/modules',
|
||||||
|
'/lookup',
|
||||||
|
'/network',
|
||||||
|
'/directories',
|
||||||
|
'/operations',
|
||||||
|
'/schedule',
|
||||||
|
'/monitoring',
|
||||||
|
'/access',
|
||||||
|
'/tenant-settings',
|
||||||
|
'/settings',
|
||||||
|
]
|
||||||
|
for (const path of candidates) {
|
||||||
|
const perm = permissionForPath(path)
|
||||||
|
if (!perm || can(perm)) return path
|
||||||
|
}
|
||||||
|
return FALLBACK_PATH
|
||||||
|
}
|
||||||
@@ -1,27 +1,29 @@
|
|||||||
/**
|
/**
|
||||||
* EvoBGP UI surface contract (ReUI Pro).
|
* EvoBGP UI surface contract (ReUI Pro).
|
||||||
*
|
*
|
||||||
* Primary surface: **Card** via `PanelCard` / `DataGridCard`.
|
* Ops / list / dashboard / detail / settings: **Frame** only.
|
||||||
* All route-level panels, KPI tiles, charts and data grids use shadcn Card —
|
* KPI tiles: **stats-12** via `KpiStatGrid` / `reui-kit/KpiStatGrid`.
|
||||||
* not ReUI Frame — for visual consistency across screens.
|
* Lists: Frame + DataGrid (`ResourcePage` / `DataGridSection`), not Card shell.
|
||||||
*
|
*
|
||||||
* ReUI Frame blocks from registry are adapted into Card composition when installed.
|
* @see https://reui.io/preview/base/stats-12
|
||||||
*
|
* @see https://reui.io/preview/base/dashboard-1
|
||||||
* @see https://reui.io/blocks — card category
|
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||||
* @see `.agents/skills/shadcn-react/SKILL.md`
|
* @see docs/ui-design-contract.md
|
||||||
*/
|
*/
|
||||||
export const UI_SURFACE = 'card' as const
|
export const UI_SURFACE = 'frame' as const
|
||||||
|
|
||||||
/** Shared padding for KPI / metric tiles inside PanelCard. */
|
/** Grid for ReUI stats-12 KPI rows (3–6 tiles). */
|
||||||
export const kpiCardContentClassName = 'flex flex-col items-start gap-4 p-5'
|
export const kpiStatGridClassName = '@container w-full'
|
||||||
|
|
||||||
/** Compact 6-tile KPI row on dashboard overview. */
|
/** @deprecated Use kpiStatGridClassName — kept for legacy imports. */
|
||||||
export const dashboardKpiGridClassName =
|
export const dashboardKpiGridClassName = kpiStatGridClassName
|
||||||
'grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6'
|
|
||||||
|
|
||||||
/** Grid for dashboard-5 style KPI sparkline row (4 columns at xl). */
|
/** @deprecated Sparkline KPI row replaced by stats-12 grid. */
|
||||||
export const kpiGridClassName =
|
export const kpiGridClassName =
|
||||||
'grid grid-cols-1 gap-4 @3xl:grid-cols-2 @6xl:grid-cols-4'
|
'grid grid-cols-1 gap-5 @3xl:grid-cols-2 @6xl:grid-cols-4'
|
||||||
|
|
||||||
|
/** @deprecated */
|
||||||
|
export const kpiCardContentClassName = 'flex flex-col items-start gap-4 p-5'
|
||||||
|
|
||||||
/** Two-column chart panel row (monitoring / network overview). */
|
/** Two-column chart panel row (monitoring / network overview). */
|
||||||
export const chartPanelGridClassName = 'grid grid-cols-1 gap-4 @5xl:grid-cols-2'
|
export const chartPanelGridClassName = 'grid grid-cols-1 gap-4 @5xl:grid-cols-2'
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { queryOptions } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import { ensureAuthConfig } from '@/lib/auth'
|
||||||
|
import {
|
||||||
|
DEFAULT_APP_SWITCHER_CONFIG,
|
||||||
|
parseAppSwitcherConfig,
|
||||||
|
type AppSwitcherConfig,
|
||||||
|
} from '@/lib/app-switcher-config'
|
||||||
|
|
||||||
|
export const appSwitcherQueryKey = ['app-switcher', 'portal'] as const
|
||||||
|
|
||||||
|
/** Portal contract shape: `{ menuLabel, apps: [{ id, name, url, icon, enabled }] }`. */
|
||||||
|
async function fetchPortalAppSwitcher(): Promise<AppSwitcherConfig> {
|
||||||
|
const { portalUrl } = await ensureAuthConfig()
|
||||||
|
const base = portalUrl.replace(/\/$/, '')
|
||||||
|
const res = await fetch(`${base}/api/v1/app-switcher`, {
|
||||||
|
headers: { Accept: 'application/json' },
|
||||||
|
})
|
||||||
|
if (!res.ok) throw new Error(`app-switcher ${res.status}`)
|
||||||
|
const raw = (await res.json()) as unknown
|
||||||
|
return parseAppSwitcherConfig(JSON.stringify(raw))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function appSwitcherQueryOptions() {
|
||||||
|
return queryOptions({
|
||||||
|
queryKey: appSwitcherQueryKey,
|
||||||
|
queryFn: fetchPortalAppSwitcher,
|
||||||
|
staleTime: 60_000,
|
||||||
|
placeholderData: DEFAULT_APP_SWITCHER_CONFIG,
|
||||||
|
retry: 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
import { apiJSON } from '@/lib/api-client'
|
|
||||||
import type {
|
|
||||||
FirewallClient,
|
|
||||||
FirewallClientsResponse,
|
|
||||||
FirewallInstallContext,
|
|
||||||
FirewallRule,
|
|
||||||
FirewallRulesResponse,
|
|
||||||
} from '@/types/api'
|
|
||||||
|
|
||||||
export const firewallKeys = {
|
|
||||||
all: ['firewall'] as const,
|
|
||||||
clients: () => [...firewallKeys.all, 'clients'] as const,
|
|
||||||
installContext: () => [...firewallKeys.all, 'install-context'] as const,
|
|
||||||
rules: (scope: string, clientId?: string) =>
|
|
||||||
[...firewallKeys.all, 'rules', scope, clientId ?? ''] as const,
|
|
||||||
}
|
|
||||||
|
|
||||||
export function firewallInstallContextQueryOptions() {
|
|
||||||
return queryOptions<FirewallInstallContext>({
|
|
||||||
queryKey: firewallKeys.installContext(),
|
|
||||||
queryFn: () => apiJSON<FirewallInstallContext>('/v1/firewall/install-context'),
|
|
||||||
staleTime: 60_000,
|
|
||||||
retry: false,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function firewallClientsQueryOptions() {
|
|
||||||
return queryOptions<FirewallClientsResponse>({
|
|
||||||
queryKey: firewallKeys.clients(),
|
|
||||||
queryFn: () => apiJSON<FirewallClientsResponse>('/v1/firewall/clients'),
|
|
||||||
staleTime: 15_000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function firewallRulesQueryOptions(scope: 'tenant' | 'client', clientId?: string) {
|
|
||||||
const qs =
|
|
||||||
scope === 'client' && clientId
|
|
||||||
? `?scope=client&client_id=${encodeURIComponent(clientId)}`
|
|
||||||
: '?scope=tenant'
|
|
||||||
return queryOptions<FirewallRulesResponse>({
|
|
||||||
queryKey: firewallKeys.rules(scope, clientId),
|
|
||||||
queryFn: () => apiJSON<FirewallRulesResponse>(`/v1/firewall/rules${qs}`),
|
|
||||||
staleTime: 15_000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useApproveFirewallClient() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (id: string) =>
|
|
||||||
apiJSON<FirewallClient>(`/v1/firewall/clients/${id}/approve`, { method: 'POST' }),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('Клиент одобрен')
|
|
||||||
void qc.invalidateQueries({ queryKey: firewallKeys.clients() })
|
|
||||||
},
|
|
||||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось одобрить'),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteFirewallClient() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (id: string) =>
|
|
||||||
apiJSON<void>(`/v1/firewall/clients/${id}`, { method: 'DELETE' }),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('Клиент удалён')
|
|
||||||
void qc.invalidateQueries({ queryKey: firewallKeys.clients() })
|
|
||||||
},
|
|
||||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось удалить'),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCreateFirewallRule() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (body: Record<string, unknown>) =>
|
|
||||||
apiJSON<FirewallRule>('/v1/firewall/rules', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
}),
|
|
||||||
onSuccess: () => {
|
|
||||||
toast.success('Правило добавлено')
|
|
||||||
void qc.invalidateQueries({ queryKey: firewallKeys.all })
|
|
||||||
},
|
|
||||||
onError: (e) => toast.error(e instanceof Error ? e.message : 'Не удалось добавить правило'),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useDeleteFirewallRule() {
|
|
||||||
const qc = useQueryClient()
|
|
||||||
return useMutation({
|
|
||||||
mutationFn: (id: string) =>
|
|
||||||
apiJSON<void>(`/v1/firewall/rules/${id}`, { method: 'DELETE' }),
|
|
||||||
onSuccess: () => {
|
|
||||||
void qc.invalidateQueries({ queryKey: firewallKeys.all })
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { queryOptions } from '@tanstack/react-query'
|
||||||
|
|
||||||
|
import { apiJSON } from '@/lib/api-client'
|
||||||
|
import type { LookupResponse } from '@/types/api'
|
||||||
|
|
||||||
|
export const lookupKeys = {
|
||||||
|
all: ['lookup'] as const,
|
||||||
|
query: (q: string) => [...lookupKeys.all, q] as const,
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET /v1/lookup?q= — dual-layer membership (entry + snapshot). */
|
||||||
|
export function lookupQueryOptions(q: string) {
|
||||||
|
const trimmed = q.trim()
|
||||||
|
return queryOptions<LookupResponse>({
|
||||||
|
queryKey: lookupKeys.query(trimmed),
|
||||||
|
queryFn: () =>
|
||||||
|
apiJSON<LookupResponse>(`/v1/lookup?q=${encodeURIComponent(trimmed)}`),
|
||||||
|
enabled: trimmed.length > 0,
|
||||||
|
staleTime: 15_000,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,15 +1,69 @@
|
|||||||
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
|
import { createFileRoute, Outlet, redirect } from '@tanstack/react-router'
|
||||||
|
|
||||||
import { normalizeApiToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
import { normalizeApiToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
||||||
|
import {
|
||||||
|
can,
|
||||||
|
ensureAuthConfig,
|
||||||
|
firstAllowedPath,
|
||||||
|
getClaims,
|
||||||
|
getPortalToken,
|
||||||
|
permissionForPath,
|
||||||
|
redirectToPortalLogin,
|
||||||
|
} from '@/lib/auth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Two-mode gate:
|
||||||
|
* - VITE_AUTH_ENABLED / API `/v1/auth/config { required: true }`
|
||||||
|
* → require auth-portal JWT (SSO) + section permission via `can()`.
|
||||||
|
* - Off → keep legacy `evobgp_api_token` gate (redirect to /settings if empty).
|
||||||
|
*/
|
||||||
export const Route = createFileRoute('/_auth')({
|
export const Route = createFileRoute('/_auth')({
|
||||||
beforeLoad: ({ location }) => {
|
beforeLoad: async ({ location }) => {
|
||||||
// Настройки доступны без токена — сюда попадают при первом входе (в т.ч. для `dev`).
|
const cfg = await ensureAuthConfig()
|
||||||
|
|
||||||
|
if (cfg.required) {
|
||||||
|
const token = getPortalToken()
|
||||||
|
const claims = getClaims()
|
||||||
|
if (!token || !claims) {
|
||||||
|
const ok = redirectToPortalLogin(
|
||||||
|
`${window.location.origin}/auth/callback`,
|
||||||
|
)
|
||||||
|
if (!ok) {
|
||||||
|
throw redirect({
|
||||||
|
to: '/auth/callback',
|
||||||
|
search: { error: 'sso_loop' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await new Promise(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!claims.apps.includes('bgp')) {
|
||||||
|
throw redirect({
|
||||||
|
to: '/auth/callback',
|
||||||
|
search: { error: 'sso_loop' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const perm = permissionForPath(location.pathname)
|
||||||
|
if (perm && !can(perm)) {
|
||||||
|
const fallback = firstAllowedPath()
|
||||||
|
if (fallback !== location.pathname) {
|
||||||
|
throw redirect({ to: fallback as '/dashboard' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings available without token — first-run onboarding (incl. `dev`).
|
||||||
if (location.pathname === '/settings') return
|
if (location.pathname === '/settings') return
|
||||||
const raw =
|
const raw =
|
||||||
typeof window !== 'undefined' ? window.localStorage.getItem(TOKEN_STORAGE_KEY) : null
|
typeof window !== 'undefined'
|
||||||
|
? window.localStorage.getItem(TOKEN_STORAGE_KEY)
|
||||||
|
: null
|
||||||
if (!raw || !normalizeApiToken(raw)) {
|
if (!raw || !normalizeApiToken(raw)) {
|
||||||
throw redirect({ to: '/settings', search: { reason: 'token-required' } })
|
throw redirect({
|
||||||
|
to: '/settings',
|
||||||
|
search: { tab: 'connection', reason: 'token-required' },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
component: AuthLayout,
|
component: AuthLayout,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { PanelCard } from '@/components/panel-card'
|
|||||||
|
|
||||||
import { AccessApiKeysCard } from '@/components/access/access-api-keys-card'
|
import { AccessApiKeysCard } from '@/components/access/access-api-keys-card'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
||||||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||||
import { authSessionQueryOptions } from '@/queries/auth'
|
import { authSessionQueryOptions } from '@/queries/auth'
|
||||||
@@ -39,20 +40,32 @@ function AccessComponent() {
|
|||||||
label: 'Всего ключей',
|
label: 'Всего ключей',
|
||||||
value: keys.length,
|
value: keys.length,
|
||||||
icon: <KeyRound className="size-4" />,
|
icon: <KeyRound className="size-4" />,
|
||||||
hint: 'в tenant',
|
badge: (
|
||||||
|
<Badge variant="primary-light" size="sm">
|
||||||
|
в tenant
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Активных',
|
label: 'Активных',
|
||||||
value: activeCount,
|
value: activeCount,
|
||||||
icon: <ShieldCheck className="size-4" />,
|
icon: <ShieldCheck className="size-4" />,
|
||||||
hint: 'не отозваны',
|
badge: (
|
||||||
|
<Badge variant="success-light" size="sm">
|
||||||
|
не отозваны
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Отозванных',
|
label: 'Отозванных',
|
||||||
value: revokedCount,
|
value: revokedCount,
|
||||||
icon: <ShieldOff className="size-4" />,
|
icon: <ShieldOff className="size-4" />,
|
||||||
hint: 'revoked',
|
|
||||||
variant: revokedCount > 0 ? 'warning' : 'default',
|
variant: revokedCount > 0 ? 'warning' : 'default',
|
||||||
|
badge: (
|
||||||
|
<Badge variant={revokedCount > 0 ? 'warning-light' : 'outline'} size="sm">
|
||||||
|
revoked
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
[keys.length, activeCount, revokedCount],
|
[keys.length, activeCount, revokedCount],
|
||||||
@@ -96,7 +109,11 @@ function AccessComponent() {
|
|||||||
) : (
|
) : (
|
||||||
<PanelCard contentClassName="py-4 text-sm text-muted-foreground">
|
<PanelCard contentClassName="py-4 text-sm text-muted-foreground">
|
||||||
Не удалось определить сессию. Укажите токен в{' '}
|
Не удалось определить сессию. Укажите токен в{' '}
|
||||||
<Link to="/settings" className="text-primary underline-offset-4 hover:underline">
|
<Link
|
||||||
|
to="/settings"
|
||||||
|
search={{ tab: 'connection' }}
|
||||||
|
className="text-primary underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
настройках
|
настройках
|
||||||
</Link>{' '}
|
</Link>{' '}
|
||||||
(для dev-окружения — <code className="text-xs">dev</code> при включённом demo-seed).
|
(для dev-окружения — <code className="text-xs">dev</code> при включённом demo-seed).
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useQueries } from '@tanstack/react-query'
|
import { useQueries, useQuery } from '@tanstack/react-query'
|
||||||
import { RefreshCw } from 'lucide-react'
|
import { RefreshCw } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
@@ -27,13 +27,21 @@ import {
|
|||||||
overviewRevisionsQueryOptions,
|
overviewRevisionsQueryOptions,
|
||||||
overviewSpeakersQueryOptions,
|
overviewSpeakersQueryOptions,
|
||||||
} from '@/queries/overview'
|
} from '@/queries/overview'
|
||||||
|
import { settingsQueryOptions } from '@/queries/settings'
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/dashboard')({
|
export const Route = createFileRoute('/_auth/dashboard')({
|
||||||
component: DashboardComponent,
|
component: DashboardComponent,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function parseShowQuickActions(value: unknown): boolean {
|
||||||
|
if (value === false || value === 0 || value === 'false' || value === '0') return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
function DashboardComponent() {
|
function DashboardComponent() {
|
||||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
|
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
|
||||||
|
const settingsQ = useQuery(settingsQueryOptions())
|
||||||
|
const showQuickActions = parseShowQuickActions(settingsQ.data?.ui_show_quick_actions)
|
||||||
|
|
||||||
const results = useQueries({
|
const results = useQueries({
|
||||||
queries: [
|
queries: [
|
||||||
@@ -86,8 +94,6 @@ function DashboardComponent() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DashboardQuickLinks />
|
|
||||||
|
|
||||||
{initialLoading ? (
|
{initialLoading ? (
|
||||||
<AnalyticsDashboardSkeleton />
|
<AnalyticsDashboardSkeleton />
|
||||||
) : (
|
) : (
|
||||||
@@ -99,6 +105,8 @@ function DashboardComponent() {
|
|||||||
jobs={jobs}
|
jobs={jobs}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{showQuickActions ? <DashboardQuickLinks /> : null}
|
||||||
|
|
||||||
<div className={dashboardMainSidebarClassName}>
|
<div className={dashboardMainSidebarClassName}>
|
||||||
<div className="xl:col-span-8">
|
<div className="xl:col-span-8">
|
||||||
<DashboardModulesGrid modules={modules} isLoading={refreshing} />
|
<DashboardModulesGrid modules={modules} isLoading={refreshing} />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
|
|||||||
import { BookText, Globe, RefreshCw, Tags } from 'lucide-react'
|
import { BookText, Globe, RefreshCw, Tags } from 'lucide-react'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
||||||
import { DataGridCard } from '@/components/data-grid-shell'
|
import { DataGridCard } from '@/components/data-grid-shell'
|
||||||
import { DirectoriesCommunitiesGrid } from '@/components/directories/directories-communities-grid'
|
import { DirectoriesCommunitiesGrid } from '@/components/directories/directories-communities-grid'
|
||||||
@@ -30,19 +31,31 @@ function DirectoriesComponent() {
|
|||||||
label: 'Сообщества BGP',
|
label: 'Сообщества BGP',
|
||||||
value: communities.length,
|
value: communities.length,
|
||||||
icon: <Tags className="size-4" />,
|
icon: <Tags className="size-4" />,
|
||||||
hint: 'теги префиксов в AS- и CDN-модулях',
|
badge: (
|
||||||
|
<Badge variant="primary-light" size="sm">
|
||||||
|
теги префиксов
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'DoH профили',
|
label: 'DoH профили',
|
||||||
value: dohProfiles.length,
|
value: dohProfiles.length,
|
||||||
icon: <Globe className="size-4" />,
|
icon: <Globe className="size-4" />,
|
||||||
hint: 'резолвинг доменных модулей',
|
badge: (
|
||||||
|
<Badge variant="info-light" size="sm">
|
||||||
|
резолвинг доменов
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Справочники',
|
label: 'Справочники',
|
||||||
value: 'Общие',
|
value: 'Общие',
|
||||||
icon: <BookText className="size-4" />,
|
icon: <BookText className="size-4" />,
|
||||||
hint: 'используются всеми модулями tenant',
|
badge: (
|
||||||
|
<Badge variant="outline" size="sm">
|
||||||
|
все модули tenant
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,276 +0,0 @@
|
|||||||
import { createFileRoute } from '@tanstack/react-router'
|
|
||||||
import { useQuery } from '@tanstack/react-query'
|
|
||||||
import { Copy, Plus, RefreshCw, Shield } from 'lucide-react'
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
|
||||||
import { PanelCard } from '@/components/panel-card'
|
|
||||||
import { Input } from '@evobgp/ui/components/input'
|
|
||||||
import { Label } from '@evobgp/ui/components/label'
|
|
||||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
|
||||||
import { DataGridCard } from '@/components/data-grid-shell'
|
|
||||||
import { FirewallClientsGrid } from '@/components/firewall/firewall-clients-grid'
|
|
||||||
import { FirewallRuleCreateDialog } from '@/components/firewall/firewall-rule-create-dialog'
|
|
||||||
import { FirewallRulesGrid } from '@/components/firewall/firewall-rules-grid'
|
|
||||||
import { PageHeader } from '@/components/page-header'
|
|
||||||
import { QueryState } from '@/components/query-state'
|
|
||||||
import { TableSkeleton } from '@/components/skeletons'
|
|
||||||
import { directoriesCommunitiesQueryOptions } from '@/queries/directories'
|
|
||||||
import {
|
|
||||||
firewallClientsQueryOptions,
|
|
||||||
firewallInstallContextQueryOptions,
|
|
||||||
firewallRulesQueryOptions,
|
|
||||||
useApproveFirewallClient,
|
|
||||||
useDeleteFirewallClient,
|
|
||||||
useDeleteFirewallRule,
|
|
||||||
} from '@/queries/firewall'
|
|
||||||
|
|
||||||
function httpsOrigin(origin: string): string {
|
|
||||||
try {
|
|
||||||
const u = new URL(origin)
|
|
||||||
u.protocol = 'https:'
|
|
||||||
return u.origin
|
|
||||||
} catch {
|
|
||||||
return origin.replace(/^http:/i, 'https:')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/firewall')({
|
|
||||||
component: FirewallPage,
|
|
||||||
})
|
|
||||||
|
|
||||||
function FirewallPage() {
|
|
||||||
const installCtxQ = useQuery(firewallInstallContextQueryOptions())
|
|
||||||
const communitiesQ = useQuery(directoriesCommunitiesQueryOptions())
|
|
||||||
const clientsQ = useQuery(firewallClientsQueryOptions())
|
|
||||||
const rulesQ = useQuery(firewallRulesQueryOptions('tenant'))
|
|
||||||
const approve = useApproveFirewallClient()
|
|
||||||
const deleteClient = useDeleteFirewallClient()
|
|
||||||
const deleteRule = useDeleteFirewallRule()
|
|
||||||
|
|
||||||
const installCtx = installCtxQ.data
|
|
||||||
|
|
||||||
const [clientName, setClientName] = useState('web-01')
|
|
||||||
const [cpUrl, setCpUrl] = useState(() =>
|
|
||||||
typeof window !== 'undefined' ? httpsOrigin(window.location.origin) : 'https://api.example.com',
|
|
||||||
)
|
|
||||||
const [seed, setSeed] = useState('')
|
|
||||||
const [createRuleOpen, setCreateRuleOpen] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (installCtx?.suggested_cp_url) {
|
|
||||||
setCpUrl(httpsOrigin(installCtx.suggested_cp_url))
|
|
||||||
}
|
|
||||||
if (installCtx?.bundle_seed) {
|
|
||||||
setSeed(installCtx.bundle_seed)
|
|
||||||
}
|
|
||||||
}, [installCtx?.bundle_seed, installCtx?.suggested_cp_url])
|
|
||||||
|
|
||||||
const communities = communitiesQ.data?.items ?? []
|
|
||||||
|
|
||||||
const { activeClients, pending } = useMemo(() => {
|
|
||||||
const all = clientsQ.data?.items ?? []
|
|
||||||
return {
|
|
||||||
activeClients: all.filter((c) => c.status !== 'revoked'),
|
|
||||||
pending: all.filter((c) => c.status === 'pending'),
|
|
||||||
}
|
|
||||||
}, [clientsQ.data?.items])
|
|
||||||
const rules = rulesQ.data?.items ?? []
|
|
||||||
|
|
||||||
const installCmd = useMemo(() => {
|
|
||||||
const s = seed.trim() || '<bundle_seed_hex>'
|
|
||||||
return `curl -fsSL ${cpUrl.replace(/\/$/, '')}/v1/firewall/install.sh | \\
|
|
||||||
EVOBGP_CP_URL=${cpUrl.replace(/\/$/, '')} \\
|
|
||||||
EVOBGP_SEED=${s} \\
|
|
||||||
EVOBGP_CLIENT_NAME="${clientName}" \\
|
|
||||||
bash`
|
|
||||||
}, [clientName, cpUrl, seed])
|
|
||||||
|
|
||||||
async function copyInstall() {
|
|
||||||
if (!seed.trim()) {
|
|
||||||
toast.error(
|
|
||||||
installCtx?.bundle_seed_configured === false
|
|
||||||
? 'На CP не задан EVOBGP_BUNDLE_SEED_HEX'
|
|
||||||
: 'Seed бандла недоступен (нужна роль оператора)',
|
|
||||||
)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
await navigator.clipboard.writeText(installCmd)
|
|
||||||
toast.success('Команда скопирована')
|
|
||||||
} catch {
|
|
||||||
toast.error('Не удалось скопировать')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-6">
|
|
||||||
<PageHeader
|
|
||||||
title="Файрвол: blocklist"
|
|
||||||
description="Linux-серверы: синхронизация CIDR по policy block/accept"
|
|
||||||
actions={
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => {
|
|
||||||
void clientsQ.refetch()
|
|
||||||
void rulesQ.refetch()
|
|
||||||
}}
|
|
||||||
disabled={clientsQ.isFetching}
|
|
||||||
>
|
|
||||||
<RefreshCw className={clientsQ.isFetching ? 'animate-spin' : ''} />
|
|
||||||
Обновить
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<PanelCard
|
|
||||||
title={
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<Shield className="size-4" />
|
|
||||||
Установка на сервер
|
|
||||||
</span>
|
|
||||||
}
|
|
||||||
description="Команда для root на целевом Linux (bash, curl). После регистрации — одобрите клиента во вкладке «Запросы»."
|
|
||||||
contentClassName="flex flex-col gap-4 py-4"
|
|
||||||
>
|
|
||||||
<div className="grid gap-4 sm:grid-cols-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="fw-name">Имя сервера</Label>
|
|
||||||
<Input id="fw-name" value={clientName} onChange={(e) => setClientName(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="fw-url">URL API</Label>
|
|
||||||
<Input id="fw-url" value={cpUrl} onChange={(e) => setCpUrl(e.target.value)} />
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label htmlFor="fw-seed">Seed бандла</Label>
|
|
||||||
<Input
|
|
||||||
id="fw-seed"
|
|
||||||
type="password"
|
|
||||||
readOnly
|
|
||||||
placeholder="EVOBGP_BUNDLE_SEED_HEX"
|
|
||||||
value={seed}
|
|
||||||
className="font-mono text-xs"
|
|
||||||
/>
|
|
||||||
<p className="text-muted-foreground text-xs">
|
|
||||||
{installCtxQ.isLoading
|
|
||||||
? 'Загрузка с плоскости управления…'
|
|
||||||
: installCtx?.bundle_seed_configured
|
|
||||||
? 'Из переменной EVOBGP_BUNDLE_SEED_HEX на CP (docker compose / .env)'
|
|
||||||
: 'На CP не задан EVOBGP_BUNDLE_SEED_HEX — регистрация невозможна'}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<pre className="overflow-x-auto rounded-md border bg-muted/40 p-3 text-xs">{installCmd}</pre>
|
|
||||||
<Button variant="outline" size="sm" className="w-fit" onClick={copyInstall}>
|
|
||||||
<Copy />
|
|
||||||
Копировать команду
|
|
||||||
</Button>
|
|
||||||
</PanelCard>
|
|
||||||
|
|
||||||
<BadgeTabs
|
|
||||||
defaultValue="clients"
|
|
||||||
items={[
|
|
||||||
{ value: 'clients', label: 'Клиенты', count: activeClients.length },
|
|
||||||
{ value: 'rules', label: 'Правила', count: rules.length, badgeVariant: 'info-light' },
|
|
||||||
{
|
|
||||||
value: 'requests',
|
|
||||||
label: 'Запросы',
|
|
||||||
count: pending.length,
|
|
||||||
badgeVariant: pending.length > 0 ? 'warning-light' : 'primary-light',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<TabsContent value="clients" className="mt-0">
|
|
||||||
<DataGridCard title="Клиенты" description="Активные Linux-серверы с синхронизацией списка блокировок">
|
|
||||||
<QueryState
|
|
||||||
data={clientsQ.data}
|
|
||||||
isLoading={clientsQ.isLoading}
|
|
||||||
isError={clientsQ.isError}
|
|
||||||
error={clientsQ.error}
|
|
||||||
onRetry={() => void clientsQ.refetch()}
|
|
||||||
skeleton={<TableSkeleton rows={5} cols={6} />}
|
|
||||||
>
|
|
||||||
{() => (
|
|
||||||
<FirewallClientsGrid
|
|
||||||
clients={activeClients}
|
|
||||||
isLoading={clientsQ.isFetching && !clientsQ.isLoading}
|
|
||||||
onApprove={(id) => approve.mutate(id)}
|
|
||||||
onReject={(id) => deleteClient.mutate(id)}
|
|
||||||
approvePending={approve.isPending}
|
|
||||||
rejectPending={deleteClient.isPending}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</QueryState>
|
|
||||||
</DataGridCard>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="rules" className="mt-0">
|
|
||||||
<DataGridCard
|
|
||||||
title="Правила"
|
|
||||||
actions={
|
|
||||||
<Button size="sm" type="button" onClick={() => setCreateRuleOpen(true)}>
|
|
||||||
<Plus />
|
|
||||||
Добавить правило
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<QueryState
|
|
||||||
data={rulesQ.data}
|
|
||||||
isLoading={rulesQ.isLoading}
|
|
||||||
isError={rulesQ.isError}
|
|
||||||
error={rulesQ.error}
|
|
||||||
onRetry={() => void rulesQ.refetch()}
|
|
||||||
skeleton={<TableSkeleton rows={5} cols={5} />}
|
|
||||||
>
|
|
||||||
{() => (
|
|
||||||
<FirewallRulesGrid
|
|
||||||
rules={rules}
|
|
||||||
communities={communities}
|
|
||||||
isLoading={rulesQ.isFetching && !rulesQ.isLoading}
|
|
||||||
onDelete={(id) => deleteRule.mutate(id)}
|
|
||||||
deletePending={deleteRule.isPending}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</QueryState>
|
|
||||||
</DataGridCard>
|
|
||||||
<FirewallRuleCreateDialog
|
|
||||||
open={createRuleOpen}
|
|
||||||
onOpenChange={setCreateRuleOpen}
|
|
||||||
communities={communities}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="requests" className="mt-0">
|
|
||||||
<DataGridCard
|
|
||||||
title="Запросы"
|
|
||||||
description="Запросы на регистрацию — одобрите или отклоните новые клиенты"
|
|
||||||
>
|
|
||||||
<QueryState
|
|
||||||
data={clientsQ.data}
|
|
||||||
isLoading={clientsQ.isLoading}
|
|
||||||
isError={clientsQ.isError}
|
|
||||||
error={clientsQ.error}
|
|
||||||
onRetry={() => void clientsQ.refetch()}
|
|
||||||
skeleton={<TableSkeleton rows={3} cols={6} />}
|
|
||||||
>
|
|
||||||
{() => (
|
|
||||||
<FirewallClientsGrid
|
|
||||||
clients={pending}
|
|
||||||
isLoading={clientsQ.isFetching && !clientsQ.isLoading}
|
|
||||||
onApprove={(id) => approve.mutate(id)}
|
|
||||||
onReject={(id) => deleteClient.mutate(id)}
|
|
||||||
approvePending={approve.isPending}
|
|
||||||
rejectPending={deleteClient.isPending}
|
|
||||||
emptyTitle="Нет ожидающих запросов"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</QueryState>
|
|
||||||
</DataGridCard>
|
|
||||||
</TabsContent>
|
|
||||||
</BadgeTabs>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
|
|
||||||
|
import { LookupMatchesGrid } from '@/components/lookup/lookup-matches-grid'
|
||||||
|
import { LookupSearchForm } from '@/components/lookup/lookup-search-form'
|
||||||
|
import { LookupSummaryKpi } from '@/components/lookup/lookup-summary-kpi'
|
||||||
|
import { PageHeader } from '@/components/page-header'
|
||||||
|
import { EmptyState } from '@/components/empty-state'
|
||||||
|
import { QueryState } from '@/components/query-state'
|
||||||
|
import { SectionCardsSkeleton, TableSkeleton } from '@/components/skeletons'
|
||||||
|
import { lookupQueryOptions } from '@/queries/lookup'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick membership lookup page.
|
||||||
|
* Surface: frame · KPI: stats-12 · form: form-7 · grid: data-grid-filtering-2 · empty: empty-state-2
|
||||||
|
* @see https://reui.io/preview/base/stats-12
|
||||||
|
* @see https://reui.io/preview/base/form-7
|
||||||
|
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||||
|
* @see https://reui.io/preview/base/empty-state-2
|
||||||
|
*/
|
||||||
|
export const Route = createFileRoute('/_auth/lookup')({
|
||||||
|
component: LookupComponent,
|
||||||
|
validateSearch: (search: Record<string, unknown>) => ({
|
||||||
|
q: typeof search.q === 'string' ? search.q : '',
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
|
||||||
|
function LookupComponent() {
|
||||||
|
const { q } = useSearch({ from: '/_auth/lookup' })
|
||||||
|
const navigate = Route.useNavigate()
|
||||||
|
const lookupQ = useQuery(lookupQueryOptions(q))
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4 md:gap-6">
|
||||||
|
<PageHeader
|
||||||
|
title="Проверка"
|
||||||
|
description="Быстрая проверка IP или домена в списках и community (entry + snapshot)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<LookupSearchForm
|
||||||
|
key={q}
|
||||||
|
initialQuery={q}
|
||||||
|
isPending={lookupQ.isFetching}
|
||||||
|
onSubmit={(next) => void navigate({ search: { q: next } })}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{!q.trim() ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Search className="size-8" />}
|
||||||
|
title="Введите IP или домен"
|
||||||
|
description="Например 8.8.8.8 или example.com — проверка по сырым entries и материализованным префиксам."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<QueryState
|
||||||
|
data={lookupQ.data}
|
||||||
|
isLoading={lookupQ.isLoading}
|
||||||
|
isError={lookupQ.isError}
|
||||||
|
error={lookupQ.error}
|
||||||
|
onRetry={() => void lookupQ.refetch()}
|
||||||
|
skeleton={
|
||||||
|
<div className="flex flex-col gap-4 md:gap-6">
|
||||||
|
<SectionCardsSkeleton />
|
||||||
|
<TableSkeleton rows={5} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{(data) => (
|
||||||
|
<div className="flex flex-col gap-4 md:gap-6">
|
||||||
|
<LookupSummaryKpi data={data} />
|
||||||
|
{data.matched ? (
|
||||||
|
<LookupMatchesGrid items={data.matches} isLoading={lookupQ.isFetching} />
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Search className="size-8" />}
|
||||||
|
title="Не найдено в списках"
|
||||||
|
description={`«${data.normalized}» отсутствует в entries и snapshots tenant.`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,28 +1,38 @@
|
|||||||
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
import { createFileRoute, useSearch } from '@tanstack/react-router'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import { PanelCard } from '@/components/panel-card'
|
import { TabsContent } from '@evobgp/ui/components/tabs'
|
||||||
import { RefreshCw } from 'lucide-react'
|
import { RefreshCw } from 'lucide-react'
|
||||||
|
|
||||||
import {
|
import { CountedLineTabs } from '@/components/counted-line-tabs'
|
||||||
DashboardNetworkCapacityCard,
|
import { NetworkKpi } from '@/components/network/network-kpi'
|
||||||
NetworkOverviewAnalyticsCard,
|
|
||||||
} from '@/components/analytics'
|
|
||||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
|
||||||
import { NetworkPeersCard } from '@/components/network/network-peers-card'
|
import { NetworkPeersCard } from '@/components/network/network-peers-card'
|
||||||
import { NetworkSpeakersCard } from '@/components/network/network-speakers-card'
|
import { NetworkSpeakersCard } from '@/components/network/network-speakers-card'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { QueryState } from '@/components/query-state'
|
import {
|
||||||
import { TableSkeleton } from '@/components/skeletons'
|
networkBirdQueryOptions,
|
||||||
import { networkBirdQueryOptions, networkPeersQueryOptions, networkSpeakersQueryOptions } from '@/queries/network'
|
networkPeersQueryOptions,
|
||||||
import { overviewJobsQueryOptions } from '@/queries/overview'
|
networkSpeakersQueryOptions,
|
||||||
|
} from '@/queries/network'
|
||||||
|
|
||||||
|
type NetworkTab = 'peers' | 'speakers'
|
||||||
|
|
||||||
|
function parseNetworkTab(value: unknown): NetworkTab {
|
||||||
|
if (value === 'speakers') return 'speakers'
|
||||||
|
// legacy: overview | control-plane → peers
|
||||||
|
return 'peers'
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Network ops page — KPI (stats-12) + peers/speakers ResourcePage lists.
|
||||||
|
* @see https://reui.io/preview/base/stats-12
|
||||||
|
* @see https://reui.io/preview/base/data-grid-filtering-2
|
||||||
|
* @see https://reui.io/preview/base/empty-state-12
|
||||||
|
*/
|
||||||
export const Route = createFileRoute('/_auth/network')({
|
export const Route = createFileRoute('/_auth/network')({
|
||||||
component: NetworkComponent,
|
component: NetworkComponent,
|
||||||
validateSearch: (search: Record<string, unknown>) => ({
|
validateSearch: (search: Record<string, unknown>) => ({
|
||||||
tab: (search.tab === 'peers' || search.tab === 'speakers' || search.tab === 'control-plane'
|
tab: parseNetworkTab(search.tab),
|
||||||
? search.tab
|
|
||||||
: 'overview') as 'overview' | 'peers' | 'speakers' | 'control-plane',
|
|
||||||
}),
|
}),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -32,12 +42,10 @@ function NetworkComponent() {
|
|||||||
const peersQ = useQuery({ ...networkPeersQueryOptions(), refetchInterval: 30_000 })
|
const peersQ = useQuery({ ...networkPeersQueryOptions(), refetchInterval: 30_000 })
|
||||||
const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 })
|
const speakersQ = useQuery({ ...networkSpeakersQueryOptions(), refetchInterval: 30_000 })
|
||||||
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
|
const birdQ = useQuery({ ...networkBirdQueryOptions(), refetchInterval: 30_000 })
|
||||||
const jobsQ = useQuery(overviewJobsQueryOptions())
|
|
||||||
|
|
||||||
const refreshing = peersQ.isFetching || speakersQ.isFetching
|
const refreshing = peersQ.isFetching || speakersQ.isFetching || birdQ.isFetching
|
||||||
const peers = peersQ.data?.items ?? []
|
const peers = peersQ.data?.items ?? []
|
||||||
const speakers = speakersQ.data?.items ?? []
|
const speakers = speakersQ.data?.items ?? []
|
||||||
const jobs = jobsQ.data?.items ?? []
|
|
||||||
const overviewLoading = peersQ.isLoading || speakersQ.isLoading
|
const overviewLoading = peersQ.isLoading || speakersQ.isLoading
|
||||||
|
|
||||||
function refetchAll() {
|
function refetchAll() {
|
||||||
@@ -47,121 +55,56 @@ function NetworkComponent() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-6">
|
<div className="flex flex-col gap-4 md:gap-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Сеть"
|
title="Сеть"
|
||||||
description="BGP-пиры, спикеры и live-метрики нод"
|
description="BGP-пиры, спикеры и статус BIRD"
|
||||||
actions={
|
actions={
|
||||||
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
<Button variant="outline" size="sm" onClick={refetchAll} disabled={refreshing}>
|
||||||
<RefreshCw className={refreshing ? 'animate-spin' : ''} />
|
<RefreshCw className={refreshing ? 'animate-spin' : undefined} />
|
||||||
Обновить
|
Обновить
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<BadgeTabs
|
<NetworkKpi
|
||||||
|
peers={peers}
|
||||||
|
speakers={speakers}
|
||||||
|
bird={birdQ.data}
|
||||||
|
loading={overviewLoading || birdQ.isLoading}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CountedLineTabs
|
||||||
value={search.tab}
|
value={search.tab}
|
||||||
onValueChange={(tab) =>
|
onValueChange={(tab) =>
|
||||||
navigate({
|
navigate({ search: { tab: tab as NetworkTab } })
|
||||||
search: {
|
|
||||||
tab: tab as 'overview' | 'peers' | 'speakers' | 'control-plane',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
items={[
|
tabs={[
|
||||||
{ value: 'overview', label: 'Обзор' },
|
{ id: 'peers', label: 'Пиры', count: peers.length },
|
||||||
{ value: 'peers', label: 'Пиры', count: peers.length },
|
{ id: 'speakers', label: 'Спикеры', count: speakers.length },
|
||||||
{ value: 'speakers', label: 'Спикеры', count: speakers.length, badgeVariant: 'info-light' },
|
|
||||||
{ value: 'control-plane', label: 'Плоскость управления' },
|
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
<TabsContent value="overview" className="mt-0">
|
<TabsContent value="peers" className="mt-4">
|
||||||
<div className="grid gap-4 lg:grid-cols-2">
|
|
||||||
<NetworkOverviewAnalyticsCard
|
|
||||||
peers={peers}
|
|
||||||
speakers={speakers}
|
|
||||||
loading={overviewLoading}
|
|
||||||
/>
|
|
||||||
<DashboardNetworkCapacityCard
|
|
||||||
peers={peers}
|
|
||||||
speakers={speakers}
|
|
||||||
jobs={jobs}
|
|
||||||
loading={overviewLoading}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<PanelCard
|
|
||||||
className="mt-4"
|
|
||||||
title="BIRD (control plane)"
|
|
||||||
description="Статус birdc на хосте API"
|
|
||||||
contentClassName="py-4"
|
|
||||||
>
|
|
||||||
<QueryState
|
|
||||||
data={birdQ.data}
|
|
||||||
isLoading={birdQ.isLoading}
|
|
||||||
isError={birdQ.isError}
|
|
||||||
error={birdQ.error}
|
|
||||||
skeleton={<TableSkeleton rows={3} cols={2} />}
|
|
||||||
onRetry={() => birdQ.refetch()}
|
|
||||||
>
|
|
||||||
{(bird) => <BirdSummary bird={bird} />}
|
|
||||||
</QueryState>
|
|
||||||
</PanelCard>
|
|
||||||
</TabsContent>
|
|
||||||
|
|
||||||
<TabsContent value="peers" className="mt-0">
|
|
||||||
<NetworkPeersCard
|
<NetworkPeersCard
|
||||||
items={peers}
|
items={peers}
|
||||||
speakers={speakers}
|
speakers={speakers}
|
||||||
isLoading={peersQ.isLoading}
|
isLoading={peersQ.isLoading}
|
||||||
isError={peersQ.isError}
|
isError={peersQ.isError}
|
||||||
error={peersQ.error}
|
error={peersQ.error}
|
||||||
onRetry={() => peersQ.refetch()}
|
onRetry={() => void peersQ.refetch()}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
<TabsContent value="speakers" className="mt-0">
|
<TabsContent value="speakers" className="mt-4">
|
||||||
<NetworkSpeakersCard
|
<NetworkSpeakersCard
|
||||||
items={speakers}
|
items={speakers}
|
||||||
isLoading={speakersQ.isLoading}
|
isLoading={speakersQ.isLoading}
|
||||||
isError={speakersQ.isError}
|
isError={speakersQ.isError}
|
||||||
error={speakersQ.error}
|
error={speakersQ.error}
|
||||||
onRetry={() => speakersQ.refetch()}
|
onRetry={() => void speakersQ.refetch()}
|
||||||
/>
|
/>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
</CountedLineTabs>
|
||||||
<TabsContent value="control-plane" className="mt-0">
|
|
||||||
<PanelCard
|
|
||||||
title="Настройки Control Plane (BIRD)"
|
|
||||||
description="Конфигурация tenant-level — в разделе «Настройки BIRD»"
|
|
||||||
contentClassName="py-4 text-sm text-muted-foreground"
|
|
||||||
>
|
|
||||||
См. раздел «Настройки BIRD».
|
|
||||||
</PanelCard>
|
|
||||||
</TabsContent>
|
|
||||||
</BadgeTabs>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function Field({ label, value }: { label: string; value: string }) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-0.5">
|
|
||||||
<span className="text-xs text-muted-foreground">{label}</span>
|
|
||||||
<span className="font-medium tabular-nums">{value}</span>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function BirdSummary({ bird }: { bird: import('@/types/api').BirdStatus }) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-2 text-sm">
|
|
||||||
<Field
|
|
||||||
label="Состояние"
|
|
||||||
value={bird.healthy === true ? 'В норме' : bird.healthy === false ? 'Проблема' : 'Н/Д'}
|
|
||||||
/>
|
|
||||||
<Field label="Сессий BGP" value={`${bird.bgp_established} / ${bird.bgp_sessions_total}`} />
|
|
||||||
{bird.message ? <p className="text-xs text-muted-foreground">{bird.message}</p> : null}
|
|
||||||
{bird.error ? <p className="text-xs text-destructive">{bird.error}</p> : null}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { createFileRoute } from '@tanstack/react-router'
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import { AlertTriangle, Clock, ListTodo, RefreshCw } from 'lucide-react'
|
import { AlertTriangle, Clock, ListTodo, RefreshCw } from 'lucide-react'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import { useEffect, useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
import { DataGridCard } from '@/components/data-grid-shell'
|
import { DataGridCard } from '@/components/data-grid-shell'
|
||||||
import { ScheduleAgendaPanel } from '@/components/schedule/schedule-agenda-panel'
|
import { ScheduleAgendaPanel } from '@/components/schedule/schedule-agenda-panel'
|
||||||
import { ScheduleJobsCard } from '@/components/schedule/schedule-jobs-card'
|
import { ScheduleJobsCard } from '@/components/schedule/schedule-jobs-card'
|
||||||
@@ -36,39 +37,37 @@ function ScheduleComponent() {
|
|||||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||||
).length
|
).length
|
||||||
|
|
||||||
// #region agent log
|
|
||||||
useEffect(() => {
|
|
||||||
fetch('http://127.0.0.1:7311/ingest/6b35c3ae-1bcd-4c9c-81eb-f157c9347393', {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': 'ce85d7' },
|
|
||||||
body: JSON.stringify({
|
|
||||||
sessionId: 'ce85d7',
|
|
||||||
runId: 'pre-fix',
|
|
||||||
hypothesisId: 'C',
|
|
||||||
location: 'schedule.tsx:mount',
|
|
||||||
message: 'schedule page data loaded',
|
|
||||||
data: {
|
|
||||||
modules: modules.length,
|
|
||||||
jobs: jobs.length,
|
|
||||||
loading,
|
|
||||||
modulesError: modulesQ.isError,
|
|
||||||
jobsError: jobsQ.isError,
|
|
||||||
},
|
|
||||||
timestamp: Date.now(),
|
|
||||||
}),
|
|
||||||
}).catch(() => {})
|
|
||||||
}, [modules.length, jobs.length, loading, modulesQ.isError, jobsQ.isError])
|
|
||||||
// #endregion
|
|
||||||
|
|
||||||
const items: SectionCardItem[] = [
|
const items: SectionCardItem[] = [
|
||||||
{ label: 'Всего задач', value: jobs.length, icon: <ListTodo className="size-4" />, hint: 'в выборке' },
|
{
|
||||||
{ label: 'В работе', value: running, icon: <Clock className="size-4" />, hint: 'в очереди и выполняются' },
|
label: 'Всего задач',
|
||||||
|
value: jobs.length,
|
||||||
|
icon: <ListTodo className="size-4" />,
|
||||||
|
badge: (
|
||||||
|
<Badge variant="primary-light" size="sm">
|
||||||
|
в выборке
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'В работе',
|
||||||
|
value: running,
|
||||||
|
icon: <Clock className="size-4" />,
|
||||||
|
badge: (
|
||||||
|
<Badge variant={running > 0 ? 'info-light' : 'outline'} size="sm">
|
||||||
|
очередь и выполнение
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'С ошибкой',
|
label: 'С ошибкой',
|
||||||
value: failed,
|
value: failed,
|
||||||
icon: <AlertTriangle className="size-4" />,
|
icon: <AlertTriangle className="size-4" />,
|
||||||
hint: failed > 0 ? 'требуют внимания' : 'без ошибок',
|
|
||||||
variant: failed > 0 ? 'warning' : 'default',
|
variant: failed > 0 ? 'warning' : 'default',
|
||||||
|
badge: (
|
||||||
|
<Badge variant={failed > 0 ? 'warning-light' : 'success-light'} size="sm">
|
||||||
|
{failed > 0 ? 'требуют внимания' : 'без ошибок'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,213 +1,51 @@
|
|||||||
import { createFileRoute, Link, useNavigate, useRouterState } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { z } from 'zod'
|
||||||
import { Moon, Save, Sun, SunMoon } from 'lucide-react'
|
|
||||||
import { useTheme } from 'next-themes'
|
|
||||||
import { useEffect, useState } from 'react'
|
|
||||||
import { toast } from 'sonner'
|
|
||||||
|
|
||||||
import { Badge } from '@/components/reui/badge'
|
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { PanelCard } from '@/components/panel-card'
|
import { SettingsPageShell } from '@/components/settings/settings-page-shell'
|
||||||
import { SettingsSettingField } from '@/components/settings/settings-setting-field'
|
|
||||||
import { DEV_API_TOKEN, normalizeApiToken, setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
|
||||||
import { authKeys, authSessionQueryOptions } from '@/queries/auth'
|
|
||||||
import { Alert, AlertDescription } from '@evobgp/ui/components/alert'
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
|
||||||
import { FieldGroup } from '@evobgp/ui/components/field'
|
|
||||||
import { Input } from '@evobgp/ui/components/input'
|
|
||||||
import {
|
import {
|
||||||
ToggleGroup,
|
type SettingsTab,
|
||||||
ToggleGroupItem,
|
} from '@/components/settings/settings-tabs-data'
|
||||||
} from '@evobgp/ui/components/toggle-group'
|
|
||||||
|
const settingsSearchSchema = z.object({
|
||||||
|
tab: z
|
||||||
|
.enum(['connection', 'session', 'appearance', 'sections'])
|
||||||
|
.catch('connection'),
|
||||||
|
reason: z.enum(['token-required']).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/settings')({
|
export const Route = createFileRoute('/_auth/settings')({
|
||||||
component: SettingsComponent,
|
component: SettingsComponent,
|
||||||
|
validateSearch: (search) => settingsSearchSchema.parse(search),
|
||||||
})
|
})
|
||||||
|
|
||||||
const THEME_OPTIONS = [
|
|
||||||
{ value: 'light', label: 'Светлая', icon: Sun },
|
|
||||||
{ value: 'dark', label: 'Тёмная', icon: Moon },
|
|
||||||
{ value: 'system', label: 'Система', icon: SunMoon },
|
|
||||||
] as const
|
|
||||||
|
|
||||||
function SettingsComponent() {
|
function SettingsComponent() {
|
||||||
const navigate = useNavigate()
|
const navigate = Route.useNavigate()
|
||||||
const tokenRequired = useRouterState({
|
const { tab, reason } = Route.useSearch()
|
||||||
select: (s) => new URLSearchParams(s.location.search).get('reason') === 'token-required',
|
const tokenRequired = reason === 'token-required'
|
||||||
})
|
|
||||||
const qc = useQueryClient()
|
|
||||||
const { data: session, isError: sessionError, error: sessionQueryError } = useQuery({
|
|
||||||
...authSessionQueryOptions(),
|
|
||||||
enabled: Boolean(
|
|
||||||
typeof window !== 'undefined' && window.localStorage.getItem(TOKEN_STORAGE_KEY)?.trim(),
|
|
||||||
),
|
|
||||||
})
|
|
||||||
const { theme, setTheme } = useTheme()
|
|
||||||
const [token, setTokenValue] = useState('')
|
|
||||||
|
|
||||||
useEffect(() => {
|
function handleTabChange(nextTab: SettingsTab) {
|
||||||
const t = window.localStorage.getItem(TOKEN_STORAGE_KEY) ?? ''
|
void navigate({
|
||||||
setTokenValue(t)
|
search: (prev) => ({
|
||||||
}, [])
|
...prev,
|
||||||
|
tab: nextTab,
|
||||||
async function applyToken(raw: string) {
|
}),
|
||||||
const normalized = normalizeApiToken(raw)
|
replace: true,
|
||||||
setToken(normalized || null)
|
})
|
||||||
setTokenValue(normalized)
|
|
||||||
await qc.invalidateQueries({ queryKey: authKeys.all })
|
|
||||||
toast.success('Токен сохранён')
|
|
||||||
if (normalized) {
|
|
||||||
void navigate({ to: '/dashboard' })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveTokenHandler() {
|
|
||||||
void applyToken(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
function useDevToken() {
|
|
||||||
void applyToken(DEV_API_TOKEN)
|
|
||||||
}
|
|
||||||
|
|
||||||
const showSessionRow = Boolean(session || sessionError)
|
|
||||||
const sessionErrorMessage =
|
|
||||||
sessionQueryError instanceof Error
|
|
||||||
? sessionQueryError.message
|
|
||||||
: 'Не удалось проверить сессию'
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex max-w-3xl flex-col gap-6">
|
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Настройки"
|
title="Настройки"
|
||||||
description="Параметры интерфейса и подключения браузера к API."
|
description="Параметры интерфейса и подключения браузера к API. Параметры tenant — во вкладке «Разделы»."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{tokenRequired ? (
|
<SettingsPageShell
|
||||||
<Alert>
|
activeTab={tab}
|
||||||
<AlertDescription>
|
onTabChange={handleTabChange}
|
||||||
Для доступа к разделу нужен API-токен. Сохраните токен ниже или нажмите «Использовать
|
tokenRequired={tokenRequired}
|
||||||
dev».
|
/>
|
||||||
</AlertDescription>
|
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<PanelCard
|
|
||||||
title="Подключение к API"
|
|
||||||
description="Токен хранится только в этом браузере (localStorage)."
|
|
||||||
contentClassName="p-0"
|
|
||||||
footer={
|
|
||||||
<div className="flex w-full flex-wrap justify-end gap-2">
|
|
||||||
<Button type="button" variant="outline" onClick={useDevToken}>
|
|
||||||
Использовать dev
|
|
||||||
</Button>
|
|
||||||
<LoadingButton onClick={saveTokenHandler}>
|
|
||||||
<Save />
|
|
||||||
Сохранить токен
|
|
||||||
</LoadingButton>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<FieldGroup className="gap-0">
|
|
||||||
<SettingsSettingField
|
|
||||||
title="Токен для запросов"
|
|
||||||
description={
|
|
||||||
<>
|
|
||||||
Ключ для заголовка <code className="text-xs">Authorization</code>. Управление
|
|
||||||
ключами tenant — в разделе{' '}
|
|
||||||
<Link to="/access" className="text-primary underline-offset-4 hover:underline">
|
|
||||||
Права доступа
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
badge={{ label: 'localStorage', variant: 'outline' }}
|
|
||||||
labelFor="token"
|
|
||||||
last={!showSessionRow}
|
|
||||||
>
|
|
||||||
<Input
|
|
||||||
id="token"
|
|
||||||
type="password"
|
|
||||||
autoComplete="off"
|
|
||||||
value={token}
|
|
||||||
onChange={(e) => setTokenValue(e.target.value)}
|
|
||||||
placeholder="dev или API-ключ"
|
|
||||||
/>
|
|
||||||
</SettingsSettingField>
|
|
||||||
|
|
||||||
{showSessionRow ? (
|
|
||||||
<SettingsSettingField
|
|
||||||
title="Статус сессии"
|
|
||||||
description={
|
|
||||||
session
|
|
||||||
? 'Проверка токена через GET /v1/auth/session.'
|
|
||||||
: 'Токен сохранён, но сессия не подтверждена API.'
|
|
||||||
}
|
|
||||||
badge={
|
|
||||||
session
|
|
||||||
? { label: session.role, variant: 'success-light' }
|
|
||||||
: { label: 'ошибка', variant: 'destructive-light' }
|
|
||||||
}
|
|
||||||
last
|
|
||||||
>
|
|
||||||
{session ? (
|
|
||||||
<div className="text-muted-foreground space-y-1 text-sm">
|
|
||||||
<p>
|
|
||||||
Tenant:{' '}
|
|
||||||
<code className="text-foreground font-mono text-xs">{session.tenant_id}</code>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
Роль: <Badge variant="info-light" size="sm">{session.role}</Badge>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="text-destructive text-sm">
|
|
||||||
{sessionErrorMessage}. Для токена <code className="font-mono">dev</code> нужен
|
|
||||||
demo-seed (<code className="text-xs">EVOBGP_SEED_DEMO</code> ≠ 0) и запущенный
|
|
||||||
API.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</SettingsSettingField>
|
|
||||||
) : null}
|
|
||||||
</FieldGroup>
|
|
||||||
</PanelCard>
|
|
||||||
|
|
||||||
<PanelCard
|
|
||||||
title="Оформление"
|
|
||||||
description="Тема интерфейса. Быстрый переключатель также доступен в боковой панели."
|
|
||||||
contentClassName="p-0"
|
|
||||||
>
|
|
||||||
<FieldGroup className="gap-0">
|
|
||||||
<SettingsSettingField
|
|
||||||
title="Тема"
|
|
||||||
description="Влияет на цветовую схему всех экранов в этом браузере."
|
|
||||||
badge={{ label: 'мгновенно', variant: 'primary-light' }}
|
|
||||||
last
|
|
||||||
>
|
|
||||||
<ToggleGroup
|
|
||||||
multiple={false}
|
|
||||||
value={[theme ?? 'system']}
|
|
||||||
onValueChange={(value) => {
|
|
||||||
if (value.length > 0) setTheme(value[0])
|
|
||||||
}}
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
aria-label="Тема интерфейса"
|
|
||||||
className="w-full sm:w-auto"
|
|
||||||
>
|
|
||||||
{THEME_OPTIONS.map((option) => {
|
|
||||||
const Icon = option.icon
|
|
||||||
return (
|
|
||||||
<ToggleGroupItem key={option.value} value={option.value} className="gap-1.5">
|
|
||||||
<Icon aria-hidden className="size-3.5" />
|
|
||||||
{option.label}
|
|
||||||
</ToggleGroupItem>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</ToggleGroup>
|
|
||||||
</SettingsSettingField>
|
|
||||||
</FieldGroup>
|
|
||||||
</PanelCard>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||||
|
|
||||||
|
import {
|
||||||
|
authPortalUrl,
|
||||||
|
clearPortalHandoffFlag,
|
||||||
|
clearPortalToken,
|
||||||
|
ensureAuthConfig,
|
||||||
|
firstAllowedPath,
|
||||||
|
getClaims,
|
||||||
|
getPortalToken,
|
||||||
|
parseHashToken,
|
||||||
|
redirectToPortalLogin,
|
||||||
|
setPortalToken,
|
||||||
|
} from '@/lib/auth'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SSO callback — reads `#access_token=…&expires_at=…` returned by auth-portal,
|
||||||
|
* stores the JWT, and drops the user on the first allowed page.
|
||||||
|
*
|
||||||
|
* If the hash is empty (direct visit / already logged in) the route re-runs the
|
||||||
|
* portal handshake; the cool-down guard prevents redirect storms.
|
||||||
|
*/
|
||||||
|
export const Route = createFileRoute('/auth/callback')({
|
||||||
|
validateSearch: (search: Record<string, unknown>) => ({
|
||||||
|
error: typeof search.error === 'string' ? search.error : undefined,
|
||||||
|
}),
|
||||||
|
beforeLoad: async ({ search }) => {
|
||||||
|
await ensureAuthConfig()
|
||||||
|
|
||||||
|
if (search.error === 'sso_loop') return
|
||||||
|
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
const { accessToken } = parseHashToken(window.location.hash)
|
||||||
|
if (accessToken) {
|
||||||
|
setPortalToken(accessToken)
|
||||||
|
clearPortalHandoffFlag()
|
||||||
|
const claims = getClaims()
|
||||||
|
if (!claims) {
|
||||||
|
clearPortalToken()
|
||||||
|
window.location.assign(authPortalUrl())
|
||||||
|
await new Promise(() => {})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw redirect({ to: firstAllowedPath() as '/dashboard' })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (getPortalToken() && getClaims()) {
|
||||||
|
clearPortalHandoffFlag()
|
||||||
|
throw redirect({ to: firstAllowedPath() as '/dashboard' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const ok = redirectToPortalLogin(`${window.location.origin}/auth/callback`)
|
||||||
|
if (!ok) {
|
||||||
|
throw redirect({
|
||||||
|
to: '/auth/callback',
|
||||||
|
search: { error: 'sso_loop' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
await new Promise(() => {})
|
||||||
|
},
|
||||||
|
component: AuthCallbackPage,
|
||||||
|
})
|
||||||
|
|
||||||
|
function AuthCallbackPage() {
|
||||||
|
const { error } = Route.useSearch()
|
||||||
|
if (error === 'sso_loop') {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-svh flex-col items-center justify-center gap-3 p-6 text-center">
|
||||||
|
<h1 className="text-lg font-semibold">Сессия не принята</h1>
|
||||||
|
<p className="text-muted-foreground max-w-md text-sm">
|
||||||
|
Повторный вход через auth-portal остановлен (защита от цикла редиректов).
|
||||||
|
Обычно это несовпадение JWT_SECRET / ISSUER или просроченный токен.
|
||||||
|
Войдите заново на portal, затем откройте EvoBGP.
|
||||||
|
</p>
|
||||||
|
<a className="text-primary text-sm underline" href={authPortalUrl()}>
|
||||||
|
Открыть Auth Portal
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
+33
-38
@@ -151,6 +151,37 @@ export type BgpCommunityCreate = {
|
|||||||
export type BgpCommunityPatch = Partial<BgpCommunityCreate>
|
export type BgpCommunityPatch = Partial<BgpCommunityCreate>
|
||||||
export type CommunitiesResponse = Page<BgpCommunity>
|
export type CommunitiesResponse = Page<BgpCommunity>
|
||||||
|
|
||||||
|
// ---- Lookup (GET /v1/lookup) ----
|
||||||
|
/** @see https://reui.io/preview/base/stats-12 — KPI summary on /lookup */
|
||||||
|
export type LookupQueryKind = 'ip' | 'domain'
|
||||||
|
export type LookupLayer = 'entry' | 'snapshot'
|
||||||
|
export type LookupMatchKind = 'ip_range' | 'domain' | 'prefix'
|
||||||
|
|
||||||
|
export type LookupMatch = {
|
||||||
|
layer: LookupLayer
|
||||||
|
module_id: string
|
||||||
|
module_name: string
|
||||||
|
module_type: ModuleType
|
||||||
|
match_kind: LookupMatchKind
|
||||||
|
matched_value: string
|
||||||
|
entry_id?: string
|
||||||
|
source?: string
|
||||||
|
community_id?: string | null
|
||||||
|
community?: string
|
||||||
|
community_title?: string
|
||||||
|
resolved_ip?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LookupResponse = {
|
||||||
|
query: string
|
||||||
|
query_kind: LookupQueryKind
|
||||||
|
normalized: string
|
||||||
|
matched: boolean
|
||||||
|
match_count: number
|
||||||
|
matches: LookupMatch[]
|
||||||
|
resolved_ips?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Peers ----
|
// ---- Peers ----
|
||||||
export type PeerSessionOnSpeaker = {
|
export type PeerSessionOnSpeaker = {
|
||||||
speaker_id: string
|
speaker_id: string
|
||||||
@@ -344,41 +375,5 @@ export type AsyncJobAccepted = {
|
|||||||
job_id: string
|
job_id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- Firewall blocklist ----
|
// Firewall blocklist types removed — the firewall subsystem moved to the standalone
|
||||||
export type FirewallClient = {
|
// EvoFirewall service. See docs/firewall.md.
|
||||||
id: string
|
|
||||||
name: string
|
|
||||||
hostname?: string
|
|
||||||
token_prefix: string
|
|
||||||
status: 'pending' | 'approved' | 'revoked'
|
|
||||||
last_seen_at?: string | null
|
|
||||||
last_seen_at_source?: string
|
|
||||||
last_apply_at?: string | null
|
|
||||||
last_apply_status?: string
|
|
||||||
last_apply_prefix_count?: number
|
|
||||||
last_apply_packets_dropped?: number
|
|
||||||
last_apply_packets_accepted?: number
|
|
||||||
last_apply_source?: string
|
|
||||||
client_version?: string
|
|
||||||
created_at: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FirewallClientsResponse = { items: FirewallClient[] }
|
|
||||||
|
|
||||||
export type FirewallRule = {
|
|
||||||
id: string
|
|
||||||
client_id?: string | null
|
|
||||||
priority: number
|
|
||||||
action: 'block' | 'accept'
|
|
||||||
community_id?: string | null
|
|
||||||
comment?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export type FirewallRulesResponse = { items: FirewallRule[] }
|
|
||||||
|
|
||||||
export type FirewallInstallContext = {
|
|
||||||
bundle_seed: string
|
|
||||||
bundle_seed_configured: boolean
|
|
||||||
suggested_cp_url: string
|
|
||||||
install_sh_url: string
|
|
||||||
}
|
|
||||||
|
|||||||
Vendored
+13
@@ -0,0 +1,13 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_APP_SWITCHER?: string
|
||||||
|
/** '1' | 'true' → require auth-portal JWT; иначе — локальный API-токен. */
|
||||||
|
readonly VITE_AUTH_ENABLED?: string
|
||||||
|
/** URL auth-portal (SSO). Пример: http://192.168.100.67:5175 или https://auth.shnt.top. */
|
||||||
|
readonly VITE_AUTH_PORTAL_URL?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -37,6 +37,11 @@ func main() {
|
|||||||
BundleSeedHex: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_SEED_HEX")),
|
BundleSeedHex: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_SEED_HEX")),
|
||||||
CORSAllowedOrigins: strings.TrimSpace(os.Getenv("EVOBGP_CORS_ORIGINS")),
|
CORSAllowedOrigins: strings.TrimSpace(os.Getenv("EVOBGP_CORS_ORIGINS")),
|
||||||
RuntimeLogsPolicyTenant: cfg.RuntimeLogsPolicyTenant,
|
RuntimeLogsPolicyTenant: cfg.RuntimeLogsPolicyTenant,
|
||||||
|
JWTSecret: firstNonEmpty(os.Getenv("EVOBGP_AUTH_JWT_SECRET"), os.Getenv("AUTH_JWT_SECRET")),
|
||||||
|
AuthIssuer: firstNonEmpty(os.Getenv("EVOBGP_AUTH_ISSUER"), os.Getenv("AUTH_ISSUER")),
|
||||||
|
AuthPortalURL: firstNonEmpty(os.Getenv("EVOBGP_AUTH_PORTAL_URL"), os.Getenv("AUTH_PORTAL_URL")),
|
||||||
|
PortalTenantID: strings.TrimSpace(os.Getenv("EVOBGP_PORTAL_TENANT_ID")),
|
||||||
|
AuthRequired: boolFromEnv("EVOBGP_AUTH_REQUIRED", "AUTH_REQUIRED"),
|
||||||
}
|
}
|
||||||
srv, err := httpapi.New(opts)
|
srv, err := httpapi.New(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -86,6 +91,31 @@ func main() {
|
|||||||
log.Printf("%s stopped", platform.ServiceName("evobgp-all"))
|
log.Printf("%s stopped", platform.ServiceName("evobgp-all"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(candidates ...string) string {
|
||||||
|
for _, c := range candidates {
|
||||||
|
if v := strings.TrimSpace(c); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolFromEnv(keys ...string) bool {
|
||||||
|
for _, k := range keys {
|
||||||
|
v := strings.TrimSpace(os.Getenv(k))
|
||||||
|
if v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch strings.ToLower(v) {
|
||||||
|
case "1", "true", "yes", "on":
|
||||||
|
return true
|
||||||
|
case "0", "false", "no", "off":
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func startBirdMetricsPoller(ctx context.Context) {
|
func startBirdMetricsPoller(ctx context.Context) {
|
||||||
sock := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET"))
|
sock := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET"))
|
||||||
if sock == "" {
|
if sock == "" {
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ func main() {
|
|||||||
SeedDemo: seedDemo,
|
SeedDemo: seedDemo,
|
||||||
BundleSeedHex: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_SEED_HEX")),
|
BundleSeedHex: strings.TrimSpace(os.Getenv("EVOBGP_BUNDLE_SEED_HEX")),
|
||||||
CORSAllowedOrigins: strings.TrimSpace(os.Getenv("EVOBGP_CORS_ORIGINS")),
|
CORSAllowedOrigins: strings.TrimSpace(os.Getenv("EVOBGP_CORS_ORIGINS")),
|
||||||
|
JWTSecret: firstNonEmpty(os.Getenv("EVOBGP_AUTH_JWT_SECRET"), os.Getenv("AUTH_JWT_SECRET")),
|
||||||
|
AuthIssuer: firstNonEmpty(os.Getenv("EVOBGP_AUTH_ISSUER"), os.Getenv("AUTH_ISSUER")),
|
||||||
|
AuthPortalURL: firstNonEmpty(os.Getenv("EVOBGP_AUTH_PORTAL_URL"), os.Getenv("AUTH_PORTAL_URL")),
|
||||||
|
PortalTenantID: strings.TrimSpace(os.Getenv("EVOBGP_PORTAL_TENANT_ID")),
|
||||||
|
AuthRequired: boolFromEnv("EVOBGP_AUTH_REQUIRED", "AUTH_REQUIRED"),
|
||||||
}
|
}
|
||||||
srv, err := httpapi.New(opts)
|
srv, err := httpapi.New(opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -78,6 +83,31 @@ func main() {
|
|||||||
log.Printf("%s stopped", platform.ServiceName("evobgp-api"))
|
log.Printf("%s stopped", platform.ServiceName("evobgp-api"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(candidates ...string) string {
|
||||||
|
for _, c := range candidates {
|
||||||
|
if v := strings.TrimSpace(c); v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolFromEnv(keys ...string) bool {
|
||||||
|
for _, k := range keys {
|
||||||
|
v := strings.TrimSpace(os.Getenv(k))
|
||||||
|
if v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch strings.ToLower(v) {
|
||||||
|
case "1", "true", "yes", "on":
|
||||||
|
return true
|
||||||
|
case "0", "false", "no", "off":
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func startBirdMetricsPoller(ctx context.Context) {
|
func startBirdMetricsPoller(ctx context.Context) {
|
||||||
sock := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET"))
|
sock := strings.TrimSpace(os.Getenv("EVOBGP_BIRDC_SOCKET"))
|
||||||
if sock == "" {
|
if sock == "" {
|
||||||
|
|||||||
@@ -15,3 +15,10 @@ WEBUI_DOMAIN=bgp.example.com
|
|||||||
WEBUI_IP_WHITELIST=203.0.113.10/32
|
WEBUI_IP_WHITELIST=203.0.113.10/32
|
||||||
LETSENCRYPT_EMAIL=[email protected]
|
LETSENCRYPT_EMAIL=[email protected]
|
||||||
CF_DNS_API_TOKEN=
|
CF_DNS_API_TOKEN=
|
||||||
|
|
||||||
|
# Auth-portal SSO → контейнер evobgp-all (не VITE_* — они только для build web)
|
||||||
|
AUTH_REQUIRED=true
|
||||||
|
AUTH_JWT_SECRET=
|
||||||
|
AUTH_ISSUER=https://auth.shnt.top
|
||||||
|
AUTH_PORTAL_URL=https://auth.shnt.top
|
||||||
|
EVOBGP_PORTAL_TENANT_ID=
|
||||||
|
|||||||
@@ -27,3 +27,16 @@ AUTO_UPDATE_INTERVAL_SEC=300
|
|||||||
AUTO_UPDATE_SERVICES=evobgp-all,evobgp-web
|
AUTO_UPDATE_SERVICES=evobgp-all,evobgp-web
|
||||||
# Защищенные сервисы, которые updater никогда не перезапускает
|
# Защищенные сервисы, которые updater никогда не перезапускает
|
||||||
AUTO_UPDATE_PROTECTED_SERVICES=bird2
|
AUTO_UPDATE_PROTECTED_SERVICES=bird2
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Auth-portal SSO (прокидывается в контейнер evobgp-all)
|
||||||
|
# VITE_* в runtime .env НЕ влияют на уже собранный web-образ —
|
||||||
|
# UI читает GET /v1/auth/config с API (AUTH_REQUIRED / AUTH_PORTAL_URL).
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
AUTH_REQUIRED=true
|
||||||
|
AUTH_JWT_SECRET=
|
||||||
|
AUTH_ISSUER=https://auth.shnt.top
|
||||||
|
AUTH_PORTAL_URL=https://auth.shnt.top
|
||||||
|
# UUID tenant из БД (обязателен для JWT). При EVOBGP_SEED_DEMO=1 смотрите лог
|
||||||
|
# старта evobgp-all / SELECT id FROM tenant LIMIT 1;
|
||||||
|
EVOBGP_PORTAL_TENANT_ID=
|
||||||
|
|||||||
@@ -136,6 +136,11 @@ services:
|
|||||||
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
|
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
|
||||||
EVOBGP_SERVICE: evobgp-all
|
EVOBGP_SERVICE: evobgp-all
|
||||||
EVOBGP_RUNTIME_LOGS_DIR: /opt/evobgp/runtime-logs
|
EVOBGP_RUNTIME_LOGS_DIR: /opt/evobgp/runtime-logs
|
||||||
|
AUTH_REQUIRED: ${AUTH_REQUIRED:-false}
|
||||||
|
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:-}
|
||||||
|
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
|
||||||
|
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-}
|
||||||
|
EVOBGP_PORTAL_TENANT_ID: ${EVOBGP_PORTAL_TENANT_ID:-}
|
||||||
EVOBGP_DEV_INSECURE: "1"
|
EVOBGP_DEV_INSECURE: "1"
|
||||||
volumes:
|
volumes:
|
||||||
- bird_etc:/etc/bird
|
- bird_etc:/etc/bird
|
||||||
|
|||||||
@@ -138,6 +138,12 @@ services:
|
|||||||
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
|
EVOBGP_BIRD_STAGING_DIR: /tmp/evobgp-bird-staging
|
||||||
EVOBGP_SERVICE: evobgp-all
|
EVOBGP_SERVICE: evobgp-all
|
||||||
EVOBGP_RUNTIME_LOGS_DIR: /opt/evobgp/runtime-logs
|
EVOBGP_RUNTIME_LOGS_DIR: /opt/evobgp/runtime-logs
|
||||||
|
# Portal SSO (JWT) — см. docs/access.md / auth-portal integrate-evobgp.md
|
||||||
|
AUTH_REQUIRED: ${AUTH_REQUIRED:-false}
|
||||||
|
AUTH_JWT_SECRET: ${AUTH_JWT_SECRET:-}
|
||||||
|
AUTH_ISSUER: ${AUTH_ISSUER:-https://auth.shnt.top}
|
||||||
|
AUTH_PORTAL_URL: ${AUTH_PORTAL_URL:-}
|
||||||
|
EVOBGP_PORTAL_TENANT_ID: ${EVOBGP_PORTAL_TENANT_ID:-}
|
||||||
# DEV ONLY — не для production (см. docs/access.md).
|
# DEV ONLY — не для production (см. docs/access.md).
|
||||||
EVOBGP_DEV_INSECURE: "1"
|
EVOBGP_DEV_INSECURE: "1"
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -2,6 +2,40 @@
|
|||||||
|
|
||||||
Как выдавать доступ к control plane API, веб-клиентам и репликам BIRD (`evobgp-node`). Секреты храните в менеджере секретов, переменных окружения оркестратора или зашифрованных файлах — не коммитьте реальные ключи в Git.
|
Как выдавать доступ к control plane API, веб-клиентам и репликам BIRD (`evobgp-node`). Секреты храните в менеджере секретов, переменных окружения оркестратора или зашифрованных файлах — не коммитьте реальные ключи в Git.
|
||||||
|
|
||||||
|
## Portal SSO (JWT)
|
||||||
|
|
||||||
|
Единый вход через **auth-portal** (app id `bgp`). См. [integrate-evobgp.md](https://git.shts.su/denozord/auth-portal/src/branch/main/docs/integrate-evobgp.md) в репозитории auth-portal.
|
||||||
|
|
||||||
|
| Переменная | Назначение |
|
||||||
|
|------------|------------|
|
||||||
|
| `AUTH_REQUIRED` / `EVOBGP_AUTH_REQUIRED` | Включить проверку portal JWT для UI |
|
||||||
|
| `AUTH_JWT_SECRET` / `EVOBGP_AUTH_JWT_SECRET` | Тот же секрет, что `JWT_SECRET` портала (HS256) |
|
||||||
|
| `AUTH_ISSUER` | Issuer JWT (как на портале) |
|
||||||
|
| `AUTH_PORTAL_URL` | URL портала (также `GET /v1/auth/config`) |
|
||||||
|
| `EVOBGP_PORTAL_TENANT_ID` | Fallback tenant для portal JWT, если в токене нет `bgp_tenant_id` / `tenants.bgp` |
|
||||||
|
|
||||||
|
Источник tenant (по приоритету):
|
||||||
|
|
||||||
|
1. JWT claim `tenants.bgp` или `bgp_tenant_id` (задаётся в auth-portal → **Админ → Приложения** → поле «EvoBGP tenant ID»)
|
||||||
|
2. Env `EVOBGP_PORTAL_TENANT_ID`
|
||||||
|
|
||||||
|
Compose: переменные `AUTH_*` / `EVOBGP_PORTAL_TENANT_ID` должны быть в `environment:` сервиса **`evobgp-all`** (см. `deploy/compose/stack.microvps-full.yaml`). Просто положить их в `.env` без проброса в контейнер недостаточно.
|
||||||
|
|
||||||
|
`VITE_AUTH_*` в runtime `.env` **не** меняют уже собранный `evobgp-web` образ. UI берёт режим из `GET /v1/auth/config` (`required` ← `AUTH_REQUIRED`, `portal_url` ← `AUTH_PORTAL_URL`).
|
||||||
|
|
||||||
|
Проверка после рестарта:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -sS https://bgp.shnt.top/v1/auth/config
|
||||||
|
# {"required":true,"portal_url":"https://auth.shnt.top"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Права — строки `bgp:<section>:<action>` из каталога портала (dashboard, modules, lookup, network, …). Apply/rollback требуют `bgp:operations:admin`.
|
||||||
|
|
||||||
|
**Ownership:** modules, peers, firewall clients/rules с `created_by_user_id` видны создателю и portal `is_admin` (API keys — весь tenant).
|
||||||
|
|
||||||
|
UI: `VITE_AUTH_ENABLED`, `VITE_AUTH_PORTAL_URL`. App Switcher: `CURRENT_APP_ID=bgp`, конфиг с `GET {portal}/api/v1/app-switcher`.
|
||||||
|
|
||||||
## API-ключи (`EVOBGP_API_KEYS`)
|
## API-ключи (`EVOBGP_API_KEYS`)
|
||||||
|
|
||||||
Формат переменной окружения: список записей через **запятую** без пробелов внутри логики парсера (пробелы вокруг записей допускаются при обрезке). Каждая запись:
|
Формат переменной окружения: список записей через **запятую** без пробелов внутри логики парсера (пробелы вокруг записей допускаются при обрезке). Каждая запись:
|
||||||
|
|||||||
@@ -33,6 +33,15 @@
|
|||||||
- `POST /v1/modules`, `PATCH /v1/modules/{module_id}`, `DELETE /v1/modules/{module_id}`
|
- `POST /v1/modules`, `PATCH /v1/modules/{module_id}`, `DELETE /v1/modules/{module_id}`
|
||||||
- `GET|POST|PATCH|DELETE` для `.../cdn-sources`, `.../as-entries`, `.../domain-entries`, `.../ip-range-entries`
|
- `GET|POST|PATCH|DELETE` для `.../cdn-sources`, `.../as-entries`, `.../domain-entries`, `.../ip-range-entries`
|
||||||
- `POST /v1/modules/{module_id}/refresh`
|
- `POST /v1/modules/{module_id}/refresh`
|
||||||
|
- `GET /v1/router-lists/catalog` — агрегированный каталог модулей/entries/communities
|
||||||
|
|
||||||
|
### Lookup
|
||||||
|
|
||||||
|
- `GET /v1/lookup?q=` — быстрая проверка IP или FQDN в списках (viewer+).
|
||||||
|
- Слой `entry`: `IP_RANGES` (`CIDR.Contains`) / `DOMAINS` (нормализованный FQDN).
|
||||||
|
- Слой `snapshot`: материализованные `module_prefix_snapshot` (для IP — Contains по всем модулям; для домена — `source=domain` у matched DOMAINS-модулей).
|
||||||
|
- В каждом матче — community (`community_id` / значение / title).
|
||||||
|
- Live DoH не выполняется. Контракт: OpenAPI `lookupMembership`.
|
||||||
|
|
||||||
### DoH profiles
|
### DoH profiles
|
||||||
|
|
||||||
|
|||||||
+9
-46
@@ -1,52 +1,15 @@
|
|||||||
# Firewall blocklist
|
# Firewall (deprecated in EvoBGP)
|
||||||
|
|
||||||
Подсистема синхронизации blocklist на произвольные Linux-серверы через bash-скрипт и HTTP API.
|
**Hard cutover:** подсистема firewall перенесена в отдельный продукт **[EvoFirewall](https://git.shts.su/denozord/EvoFirewall)**.
|
||||||
|
|
||||||
## Авторизация
|
Все HTTP-эндпоинты `/v1/firewall/*` в EvoBGP отвечают **410 Gone**.
|
||||||
|
|
||||||
1. **Enroll** — `POST /v1/firewall/enroll` с заголовком `X-EvoBGP-Seed` (значение `EVOBGP_BUNDLE_SEED_HEX` на CP). Клиент генерирует токен `evobgp_fw_*` локально.
|
Таблицы `firewall_client` / `firewall_rule` в БД оставлены (не удаляются миграциями) для истории; API/UI/scripts больше не обслуживают их.
|
||||||
2. **Approve** — operator в Web UI (`/firewall` → Запросы).
|
|
||||||
3. **Sync** — `GET /v1/firewall/blocklist` с `Authorization: Bearer <client_token>`.
|
|
||||||
|
|
||||||
## Политика block/accept
|
## Миграция клиентов
|
||||||
|
|
||||||
- **`block`** — добавить префиксы выбранного BGP community в kernel blocklist.
|
1. Разверните EvoFirewall (auth-portal app id `fw`).
|
||||||
- **`accept`** — не блокировать префиксы этого community.
|
2. Переустановите агенты one-liner'ом EvoFirewall (`/v1/agent/install.sh`).
|
||||||
- **Community** — правило применяется к префиксам с этим `community_id` в опубликованной revision; пустое значение («Все») — ко всем communities.
|
3. Для списков по community создайте IP list type `evobgp_community` и укажите `EVOBGP_API_URL` + token в настройках EvoFirewall.
|
||||||
- **Default** — accept (пустой blocklist без явных `block`).
|
|
||||||
|
|
||||||
Порядок: сначала per-server overrides клиента, затем tenant-default. Для каждого community берётся первое подходящее правило по приоритету.
|
Старые токены `evobgp_fw_*` **не** переносятся — только re-enroll.
|
||||||
|
|
||||||
Справочник communities: Web UI → Справочники, или модули с привязкой community к префиксам.
|
|
||||||
|
|
||||||
## Установка на сервер
|
|
||||||
|
|
||||||
Публичные URL (без API-ключа, вне `WEBUI_IP_WHITELIST` Traefik): `GET /v1/firewall/install.sh`, `GET /v1/firewall/sync-script`, `POST /v1/firewall/enroll`. Всегда **HTTPS**.
|
|
||||||
|
|
||||||
Требуется миграция **`000027_firewall`** в PostgreSQL (применяется при старте API с актуальным бинарём). Если enroll отвечает `503` / `database schema outdated` — перезапустите `evobgp-api` / `evobgp-all` после деплоя новой версии.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -fsSL https://<api>/v1/firewall/install.sh | \
|
|
||||||
EVOBGP_CP_URL=https://<api> \
|
|
||||||
EVOBGP_SEED=<bundle_seed_hex> \
|
|
||||||
EVOBGP_CLIENT_NAME="web-01" \
|
|
||||||
bash
|
|
||||||
```
|
|
||||||
|
|
||||||
Файлы: `/etc/evobgp/firewall.conf`, `/usr/local/sbin/evobgp-firewall.sh`, systemd timer `evobgp-firewall.timer`.
|
|
||||||
|
|
||||||
После **approve** в UI выполните на сервере (или дождитесь timer):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
sudo rm -f /var/lib/evobgp-firewall/last_hash
|
|
||||||
sudo /usr/local/sbin/evobgp-firewall.sh
|
|
||||||
sudo nft list table inet evobgp_blocklist
|
|
||||||
```
|
|
||||||
|
|
||||||
Для парсинга JSON нужен `jq` или `python3` (install.sh ставит `jq` на Debian/Ubuntu при отсутствии).
|
|
||||||
|
|
||||||
## Failover через speaker
|
|
||||||
|
|
||||||
При `EVOBGP_FIREWALL_FAILOVER_ENABLED=1` на speaker-agent CP реплицирует состояние через `POST /v1/agent/firewall-replicate`. Клиенты используют тот же DNS-домен.
|
|
||||||
|
|
||||||
См. также [access.md](access.md), [remote-speakers.md](remote-speakers.md).
|
|
||||||
|
|||||||
+158
-2
@@ -11,9 +11,15 @@ info:
|
|||||||
Ошибки - `application/problem+json` ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)).
|
Ошибки - `application/problem+json` ([RFC 9457](https://www.rfc-editor.org/rfc/rfc9457)).
|
||||||
Пагинация списков - `cursor` + `limit`; ответ содержит `items`, `next_cursor`, `has_more`.
|
Пагинация списков - `cursor` + `limit`; ответ содержит `items`, `next_cursor`, `has_more`.
|
||||||
|
|
||||||
**Роли** (матрица доступа): `viewer`, `editor`, `operator`, `node`. Нода использует отдельные пути и ключ с ролью `node`.
|
**Аутентификация (dual):**
|
||||||
|
- **API key** — `Authorization: Bearer <token>` из `EVOBGP_API_KEYS` / таблицы `api_key` (роли `viewer`/`editor`/`operator`/`node`/`firewall`).
|
||||||
|
- **Portal JWT** — HS256 от auth-portal; claim `apps` должен содержать `bgp`; права `bgp:<section>:<action>`; tenant из `tenants.bgp` / `bgp_tenant_id` или fallback `EVOBGP_PORTAL_TENANT_ID`.
|
||||||
|
Публично: `GET /v1/auth/config` → `{ required, portal_url }`.
|
||||||
|
|
||||||
Заголовок `X-Tenant-Id` допускается только для супер-ролей (явный tenant); иначе tenant берётся из API-ключа.
|
**Роли API key** (матрица): `viewer`, `editor`, `operator`, `node`. Нода использует отдельные пути и ключ с ролью `node`.
|
||||||
|
JWT permissions мапятся на ту же лестницу (`:read`→viewer, `:write`→editor, `:admin`→operator).
|
||||||
|
|
||||||
|
Заголовок `X-Tenant-Id` допускается только для супер-ролей (явный tenant); иначе tenant берётся из API-ключа / portal tenant env.
|
||||||
license:
|
license:
|
||||||
name: Proprietary
|
name: Proprietary
|
||||||
identifier: LicenseRef-Proprietary
|
identifier: LicenseRef-Proprietary
|
||||||
@@ -27,6 +33,8 @@ tags:
|
|||||||
description: Liveness, readiness и метаданные сборки. Обычно без чувствительных данных; доступ может быть шире.
|
description: Liveness, readiness и метаданные сборки. Обычно без чувствительных данных; доступ может быть шире.
|
||||||
- name: Modules
|
- name: Modules
|
||||||
description: Экземпляры модулей префиксов (AS, CDN, домены, статические IP-диапазоны) и вложенные записи. Чтение - viewer+; изменение - editor+.
|
description: Экземпляры модулей префиксов (AS, CDN, домены, статические IP-диапазоны) и вложенные записи. Чтение - viewer+; изменение - editor+.
|
||||||
|
- name: Lookup
|
||||||
|
description: Быстрая проверка membership IP/FQDN в списках (entries + module prefix snapshots) и community. Чтение - viewer+.
|
||||||
- name: DoH profiles
|
- name: DoH profiles
|
||||||
description: Профили DNS-over-HTTPS для модулей типа домены. Секрет в ответах не возвращается.
|
description: Профили DNS-over-HTTPS для модулей типа домены. Секрет в ответах не возвращается.
|
||||||
- name: Communities
|
- name: Communities
|
||||||
@@ -221,6 +229,12 @@ components:
|
|||||||
application/problem+json:
|
application/problem+json:
|
||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/Problem"
|
$ref: "#/components/schemas/Problem"
|
||||||
|
BadRequest:
|
||||||
|
description: Некорректный запрос (пустой или невалидный параметр).
|
||||||
|
content:
|
||||||
|
application/problem+json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/Problem"
|
||||||
Forbidden:
|
Forbidden:
|
||||||
description: Недостаточно прав для операции.
|
description: Недостаточно прав для операции.
|
||||||
content:
|
content:
|
||||||
@@ -751,6 +765,100 @@ components:
|
|||||||
description: Человекочитаемое название для UI и фильтров.
|
description: Человекочитаемое название для UI и фильтров.
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
|
|
||||||
|
LookupQueryKind:
|
||||||
|
type: string
|
||||||
|
enum: [ip, domain]
|
||||||
|
description: Определённый тип запроса после нормализации.
|
||||||
|
|
||||||
|
LookupLayer:
|
||||||
|
type: string
|
||||||
|
enum: [entry, snapshot]
|
||||||
|
description: |
|
||||||
|
`entry` — сырые IP_RANGES / DOMAINS entries;
|
||||||
|
`snapshot` — материализованные префиксы `module_prefix_snapshot`.
|
||||||
|
|
||||||
|
LookupMatchKind:
|
||||||
|
type: string
|
||||||
|
enum: [ip_range, domain, prefix]
|
||||||
|
description: Вид совпадения (entry CIDR, entry FQDN или snapshot prefix).
|
||||||
|
|
||||||
|
LookupMatch:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- layer
|
||||||
|
- module_id
|
||||||
|
- module_name
|
||||||
|
- module_type
|
||||||
|
- match_kind
|
||||||
|
- matched_value
|
||||||
|
properties:
|
||||||
|
layer:
|
||||||
|
$ref: "#/components/schemas/LookupLayer"
|
||||||
|
module_id:
|
||||||
|
$ref: "#/components/schemas/ResourceId"
|
||||||
|
module_name:
|
||||||
|
type: string
|
||||||
|
module_type:
|
||||||
|
$ref: "#/components/schemas/ModuleType"
|
||||||
|
match_kind:
|
||||||
|
$ref: "#/components/schemas/LookupMatchKind"
|
||||||
|
matched_value:
|
||||||
|
type: string
|
||||||
|
description: CIDR, FQDN или prefix, с которым совпал запрос.
|
||||||
|
entry_id:
|
||||||
|
type: string
|
||||||
|
description: ID entry (только для layer=entry).
|
||||||
|
source:
|
||||||
|
type: string
|
||||||
|
description: Источник строки snapshot (ip_range, domain, as, cdn, …).
|
||||||
|
community_id:
|
||||||
|
type: ["string", "null"]
|
||||||
|
community:
|
||||||
|
type: string
|
||||||
|
description: Техническое значение BGP community.
|
||||||
|
community_title:
|
||||||
|
type: string
|
||||||
|
description: Человекочитаемое название community.
|
||||||
|
resolved_ip:
|
||||||
|
type: string
|
||||||
|
description: |
|
||||||
|
IP, полученный DNS-resolve domain-запроса, из-за которого появился этот матч.
|
||||||
|
Пусто для прямого IP-запроса и для FQDN entry/snapshot без resolve.
|
||||||
|
|
||||||
|
LookupResponse:
|
||||||
|
type: object
|
||||||
|
required:
|
||||||
|
- query
|
||||||
|
- query_kind
|
||||||
|
- normalized
|
||||||
|
- matched
|
||||||
|
- match_count
|
||||||
|
- matches
|
||||||
|
properties:
|
||||||
|
query:
|
||||||
|
type: string
|
||||||
|
description: Исходная строка запроса.
|
||||||
|
query_kind:
|
||||||
|
$ref: "#/components/schemas/LookupQueryKind"
|
||||||
|
normalized:
|
||||||
|
type: string
|
||||||
|
description: Нормализованный IP или FQDN.
|
||||||
|
matched:
|
||||||
|
type: boolean
|
||||||
|
description: true, если есть хотя бы одно совпадение.
|
||||||
|
match_count:
|
||||||
|
type: integer
|
||||||
|
minimum: 0
|
||||||
|
matches:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/LookupMatch"
|
||||||
|
resolved_ips:
|
||||||
|
type: array
|
||||||
|
description: IP-адреса после live DNS resolve (только для query_kind=domain; A/AAAA).
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
|
||||||
BgpPeer:
|
BgpPeer:
|
||||||
type: object
|
type: object
|
||||||
required:
|
required:
|
||||||
@@ -1455,6 +1563,10 @@ components:
|
|||||||
description: UTC cron для автоочистки (по умолчанию `0 */6 * * *`).
|
description: UTC cron для автоочистки (по умолчанию `0 */6 * * *`).
|
||||||
runtime_logs_auto_mode:
|
runtime_logs_auto_mode:
|
||||||
$ref: "#/components/schemas/RuntimeLogCleanupMode"
|
$ref: "#/components/schemas/RuntimeLogCleanupMode"
|
||||||
|
ui_show_quick_actions:
|
||||||
|
type: boolean
|
||||||
|
description: Показывать блок «Быстрые действия» на дашборде (UI preference).
|
||||||
|
default: true
|
||||||
additionalProperties: true
|
additionalProperties: true
|
||||||
|
|
||||||
RevisionDiff:
|
RevisionDiff:
|
||||||
@@ -1776,6 +1888,50 @@ paths:
|
|||||||
default:
|
default:
|
||||||
$ref: "#/components/responses/DefaultProblem"
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
|
/v1/lookup:
|
||||||
|
get:
|
||||||
|
tags: [Lookup]
|
||||||
|
summary: Проверка IP или домена в списках
|
||||||
|
description: |
|
||||||
|
Быстрая membership-проверка по tenant:
|
||||||
|
|
||||||
|
- **IP** — слой `entry` (`IP_RANGES`, `CIDR.Contains`) и слой `snapshot`
|
||||||
|
(все module prefix snapshots, `Prefix.Contains`);
|
||||||
|
- **Domain** — слой `entry` (нормализованный FQDN в `DOMAINS`) и слой `snapshot`
|
||||||
|
(префиксы `source=domain` у matched DOMAINS-модулей, если snapshot есть);
|
||||||
|
затем **live DNS resolve** (A/AAAA через системный резолвер) и проверка
|
||||||
|
каждого полученного IP так же, как для IP-запроса (ranges + все snapshots).
|
||||||
|
|
||||||
|
Community на матче: `entry.community_id || module.default_community_id` (entry)
|
||||||
|
или `PrefixRow.community_id` (snapshot), с join к справочнику communities.
|
||||||
|
|
||||||
|
Поля `resolved_ips` / `resolved_ip` заполняются только для domain-запросов
|
||||||
|
(после успешного DNS). Ошибка DNS не даёт 5xx: FQDN-слой всё равно возвращается.
|
||||||
|
operationId: lookupMembership
|
||||||
|
parameters:
|
||||||
|
- $ref: "#/components/parameters/TenantId"
|
||||||
|
- name: q
|
||||||
|
in: query
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
minLength: 1
|
||||||
|
maxLength: 253
|
||||||
|
description: IP-адрес или FQDN для проверки.
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Результат проверки (в т.ч. matched=false при отсутствии совпадений).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: "#/components/schemas/LookupResponse"
|
||||||
|
"400":
|
||||||
|
$ref: "#/components/responses/BadRequest"
|
||||||
|
"401":
|
||||||
|
$ref: "#/components/responses/Unauthorized"
|
||||||
|
default:
|
||||||
|
$ref: "#/components/responses/DefaultProblem"
|
||||||
|
|
||||||
/v1/router-lists/catalog:
|
/v1/router-lists/catalog:
|
||||||
get:
|
get:
|
||||||
tags: [Modules]
|
tags: [Modules]
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
# UI Design Contract (ops apps)
|
||||||
|
|
||||||
|
Единый контракт для vps-tracker, CFDM и EvoBGP. Surface: **ReUI Frame**. Kit API: `apps/web/src/components/reui-kit/`.
|
||||||
|
|
||||||
|
Карта: [llms.txt](https://reui.io/llms.txt) · [Styling](https://reui.io/docs/styling) · [License](https://reui.io/docs/license-setup) · [Blocks](https://reui.io/blocks)
|
||||||
|
|
||||||
|
## Surface
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// apps/web/src/lib/ui-surface.ts
|
||||||
|
export const UI_SURFACE = 'frame' as const
|
||||||
|
```
|
||||||
|
|
||||||
|
Ops / list / dashboard / detail / settings — только **Frame**, не shadcn Card как shell. Не смешивать Card и Frame на одном ops-экране.
|
||||||
|
|
||||||
|
## Canonical PRO references
|
||||||
|
|
||||||
|
| Зона | Block | Preview |
|
||||||
|
|------|-------|---------|
|
||||||
|
| Shell | `app-shell-12` (+ cmdk/monitor где нужно) | https://reui.io/preview/base/app-shell-12 · https://reui.io/preview/base/app-shell-7 |
|
||||||
|
| KPI | horizontal compact hybrid (icon left + label/Badge + value ± variant; EvoBGP visual) | https://reui.io/preview/base/stats-12 |
|
||||||
|
| Dashboard | `dashboard-1` | https://reui.io/preview/base/dashboard-1 |
|
||||||
|
| Lists | `data-grid-filtering-2` | https://reui.io/preview/base/data-grid-filtering-2 |
|
||||||
|
| Settings | `settings-16` + SettingRow (`settings-7`) | https://reui.io/preview/base/settings-16 · https://reui.io/preview/base/settings-7 |
|
||||||
|
| Auth | `auth-13` | https://reui.io/preview/base/auth-13 |
|
||||||
|
| Empty | `empty-state-12` | https://reui.io/preview/base/empty-state-12 |
|
||||||
|
| Forms | `form-7` → Sheet/Drawer | https://reui.io/preview/base/form-7 |
|
||||||
|
| Lookup | `/lookup` — Frame form + `KpiStatGrid` + DataGrid | https://reui.io/preview/base/form-7 · https://reui.io/preview/base/stats-12 · https://reui.io/preview/base/data-grid-filtering-2 · https://reui.io/preview/base/empty-state-2 |
|
||||||
|
|
||||||
|
## Kit API (`reui-kit/`)
|
||||||
|
|
||||||
|
| Component | Role |
|
||||||
|
|-----------|------|
|
||||||
|
| `ResourcePage` | Frame + line tabs + Filters + DataGrid |
|
||||||
|
| `KpiStatGrid` | horizontal compact hybrid KPI tiles (`variant`, Badge) |
|
||||||
|
| `QuickActionGrid` | KPI-like quick action tiles under KPI (gated by `ui_show_quick_actions`) |
|
||||||
|
| `OpsDashboard` | KPI + charts + attention queue |
|
||||||
|
| `SettingsShell` | settings nav + Outlet |
|
||||||
|
| `DetailPanel` | detail Frame sections |
|
||||||
|
| `filter-utils` | apply/clear ReUI Filters |
|
||||||
|
|
||||||
|
## Dashboard layout
|
||||||
|
|
||||||
|
| App | Section order |
|
||||||
|
|-----|---------------|
|
||||||
|
| EvoBGP / CFDM | KPI → **QuickActionGrid** → charts / rest |
|
||||||
|
| vps-tracker | banner → KPI → charts → attention → **QuickActionGrid** → CSV |
|
||||||
|
|
||||||
|
Gating: KV `ui_show_quick_actions` in `global_settings` via `PATCH /v1/settings` (default `true`).
|
||||||
|
|
||||||
|
## Shared App Shell chrome
|
||||||
|
|
||||||
|
Эталон: **EvoBGP** production [`apps/web/src/components/layout/app-shell.tsx`](../apps/web/src/components/layout/app-shell.tsx) + ReUI [app-shell-12](https://reui.io/preview/base/app-shell-12).
|
||||||
|
|
||||||
|
При переключении между vps-tracker / CFDM / EvoBGP меняются **только** sidebar nav labels/hrefs и `main` content. Разметка, ширина, фон и hover chrome идентичны.
|
||||||
|
|
||||||
|
| Токен / зона | Значение |
|
||||||
|
|--------------|----------|
|
||||||
|
| `SIDEBAR_WIDTH` / `--sidebar-width` | `240px` (в `packages/ui` sidebar + Provider style) |
|
||||||
|
| Sidebar / hover colors | theme `--sidebar` / `--sidebar-accent` из `globals.css` — **без** AppShell `color-mix` override |
|
||||||
|
| Header | `h-12`, `sticky`, `border-b`, `px-4 md:px-6` |
|
||||||
|
| Header left | `SidebarTrigger` + `Separator` + Breadcrumb |
|
||||||
|
| Header right | **AppsMenu** → **SystemMonitorPopover** → **ModeToggle** (без Search в chrome) |
|
||||||
|
| Sidebar | AppSwitcher → groups (`SidebarGroupContent`) → icons `size-4` → **пустой** `SidebarFooter` |
|
||||||
|
| `main` | `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5` |
|
||||||
|
| Search | hotkey ⌘K / Ctrl+K only (не кнопка в header) |
|
||||||
|
|
||||||
|
Запрещено в chrome: `SidebarRail`, `NavUser` footer, sync-row footer, Search/Ctrl+K pill в header, issues Badge в header, muted/hover cascade на right-cluster, Provider `color-mix` для `--sidebar*`.
|
||||||
|
|
||||||
|
App Switcher: source of truth — auth-portal `GET /api/v1/app-switcher`. Id: `bgp`. Admin: portal `/admin/apps`.
|
||||||
|
|
||||||
|
QuickActionGrid icons: только semantic **text** (`text-info` / `text-primary` / …) на kit `bg-muted` — без solid `bg-primary` fills. Preview: [stats-12](https://reui.io/preview/base/stats-12).
|
||||||
|
|
||||||
|
## System monitor
|
||||||
|
|
||||||
|
`SystemMonitorPopover` in app-shell header next to `ModeToggle` (после AppsMenu). Preview: https://reui.io/preview/base/app-shell-12 · https://reui.io/preview/base/app-shell-7
|
||||||
|
|
||||||
|
## MCP workflow
|
||||||
|
|
||||||
|
1. MCP `user-reui` — `search` / `get_block` / `get_component` with `surface: "frame"`
|
||||||
|
2. Cite `previewUrl` + `docsUrl`
|
||||||
|
3. CLI from `apps/web`: `pnpm dlx shadcn@latest add @reui/...`
|
||||||
|
4. Adapt into kit — do not hand-roll KPI/grid/settings rows
|
||||||
|
5. `validate_usage` / `get_audit_checklist`
|
||||||
|
|
||||||
|
Primitives: MCP `plugin-shadcn-shadcn` + `@evobgp/ui`.
|
||||||
|
|
||||||
|
## Spacing
|
||||||
|
|
||||||
|
- AppShell main: `gap-4 md:gap-6`, `px-4 py-4 md:px-6 md:py-5` (shared chrome)
|
||||||
|
- No `space-y-*` / `space-x-*` — use `flex` + `gap-*`
|
||||||
|
- Max 1 primary CTA per screen
|
||||||
|
- Semantic tokens only (`variant="success"|"info"|"warning"`) — no raw `bg-emerald-*`
|
||||||
|
|
||||||
|
## Forbidden
|
||||||
|
|
||||||
|
- Card as ops list/dashboard shell
|
||||||
|
- Hand-rolled data tables when ReUI DataGrid exists
|
||||||
|
- Hand-rolled KPI grids when `KpiStatGrid` exists
|
||||||
|
- Mixing Card and Frame surfaces on one ops screen
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user