Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d83b8d673 | ||
|
|
2820cff988 | ||
|
|
54b7ea3bd5 | ||
|
|
57bcfcd9e1 | ||
|
|
1317a73e9a | ||
|
|
039d2f3dd9 | ||
|
|
54a0b5b966 | ||
|
|
1639ba40f3 | ||
|
|
26a96bc824 | ||
|
|
4fc5c96e63 | ||
|
|
3859983ae5 | ||
|
|
aa1779170a | ||
|
|
bbcf9d76d9 | ||
|
|
55eb2a6c89 |
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Boxes,
|
||||||
|
ListChecks,
|
||||||
|
Network,
|
||||||
|
ServerCog,
|
||||||
|
Share2,
|
||||||
|
} from 'lucide-react'
|
||||||
|
import type { ReactNode } from 'react'
|
||||||
|
|
||||||
|
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
|
||||||
|
import { Badge } from '@/components/reui/badge'
|
||||||
|
|
||||||
|
import { aggregateNetworkMetrics, runningJobCount } from '@/queries/overview'
|
||||||
|
import type { JobRow, ModuleRow, PeerRow, SpeakerRow } from '@/types/api'
|
||||||
|
|
||||||
|
type KpiCard = KpiStatItem & { icon: ReactNode }
|
||||||
|
|
||||||
|
function buildKpis({
|
||||||
|
modules,
|
||||||
|
peers,
|
||||||
|
speakers,
|
||||||
|
jobs,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
modules: ModuleRow[]
|
||||||
|
peers: PeerRow[]
|
||||||
|
speakers: SpeakerRow[]
|
||||||
|
jobs: JobRow[]
|
||||||
|
loading?: boolean
|
||||||
|
}): KpiCard[] {
|
||||||
|
const enabledModules = modules.filter((m) => m.enabled !== false).length
|
||||||
|
const network = aggregateNetworkMetrics(peers, speakers)
|
||||||
|
const peersEnabled = network.peersEnabled
|
||||||
|
const bgpPct =
|
||||||
|
peersEnabled > 0 ? Math.round((network.peersEstablished / peersEnabled) * 100) : null
|
||||||
|
const running = runningJobCount(jobs)
|
||||||
|
const failedJobs = jobs.filter((j) =>
|
||||||
|
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||||
|
).length
|
||||||
|
const offlineSpeakers = Math.max(0, speakers.length - network.speakersOnline)
|
||||||
|
const riskCount = network.peersMismatch + failedJobs + offlineSpeakers
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'modules',
|
||||||
|
icon: <Boxes aria-hidden />,
|
||||||
|
iconClassName: 'text-primary',
|
||||||
|
value: loading ? '—' : `${enabledModules}/${modules.length || 0}`,
|
||||||
|
label: 'Модули активны',
|
||||||
|
footer: (
|
||||||
|
<Badge variant="primary-light" size="sm">
|
||||||
|
{loading ? '…' : `${modules.length} всего`}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'bgp',
|
||||||
|
icon: <Network aria-hidden />,
|
||||||
|
iconClassName: 'text-info',
|
||||||
|
value: loading || bgpPct === null ? '—' : `${bgpPct}%`,
|
||||||
|
label: 'BGP готовность',
|
||||||
|
footer: (
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
bgpPct !== null && bgpPct >= 90
|
||||||
|
? 'success-light'
|
||||||
|
: bgpPct !== null && bgpPct < 70
|
||||||
|
? 'warning-light'
|
||||||
|
: 'outline'
|
||||||
|
}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{loading || bgpPct === null
|
||||||
|
? 'нет включённых пиров'
|
||||||
|
: `${network.peersEstablished} установлено`}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'peers',
|
||||||
|
icon: <Share2 aria-hidden />,
|
||||||
|
iconClassName: 'text-success',
|
||||||
|
value: loading ? '—' : `${network.peersEstablished}/${peersEnabled}`,
|
||||||
|
label: 'Пиры Established',
|
||||||
|
footer: (
|
||||||
|
<Badge variant="success-light" size="sm">
|
||||||
|
{loading ? '…' : `${network.peersTotal} в каталоге`}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'speakers',
|
||||||
|
icon: <ServerCog aria-hidden />,
|
||||||
|
iconClassName: 'text-warning',
|
||||||
|
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
|
||||||
|
label: 'Спикеры online',
|
||||||
|
footer: (
|
||||||
|
<Badge
|
||||||
|
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{loading ? '…' : 'live-снимок'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'jobs',
|
||||||
|
icon: <ListChecks aria-hidden />,
|
||||||
|
iconClassName: 'text-focus',
|
||||||
|
value: loading ? '—' : String(running),
|
||||||
|
label: 'Активные задачи',
|
||||||
|
footer: (
|
||||||
|
<Badge variant={running > 0 ? 'info-light' : 'outline'} size="sm">
|
||||||
|
{loading ? '…' : `${jobs.length} в выборке`}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'risks',
|
||||||
|
icon: <AlertTriangle aria-hidden />,
|
||||||
|
iconClassName: 'text-destructive',
|
||||||
|
value: loading ? '—' : String(riskCount),
|
||||||
|
label: 'Риски',
|
||||||
|
variant: riskCount > 0 ? 'destructive' : 'default',
|
||||||
|
footer: (
|
||||||
|
<Badge variant={riskCount > 0 ? 'destructive-light' : 'success-light'} size="sm">
|
||||||
|
{loading
|
||||||
|
? '…'
|
||||||
|
: riskCount > 0
|
||||||
|
? `${failedJobs} задач · ${network.peersMismatch} расхождений`
|
||||||
|
: 'в норме'}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DashboardKpiGrid({
|
||||||
|
modules,
|
||||||
|
peers,
|
||||||
|
speakers,
|
||||||
|
jobs,
|
||||||
|
loading,
|
||||||
|
}: {
|
||||||
|
modules: ModuleRow[]
|
||||||
|
peers: PeerRow[]
|
||||||
|
speakers: SpeakerRow[]
|
||||||
|
jobs: JobRow[]
|
||||||
|
loading?: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<KpiStatGrid
|
||||||
|
items={buildKpis({ modules, peers, speakers, jobs, loading })}
|
||||||
|
aria-label="KPI обзора"
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
KeyRound,
|
KeyRound,
|
||||||
ServerCog,
|
ServerCog,
|
||||||
Shield,
|
Shield,
|
||||||
|
Search,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -41,9 +42,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 +56,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 +81,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' },
|
||||||
],
|
],
|
||||||
@@ -92,13 +100,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 +136,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 +160,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 +195,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 +219,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 }
|
||||||
@@ -1,15 +1,69 @@
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { format, isSameDay, parseISO } from 'date-fns'
|
import { format, isSameDay, parseISO } from 'date-fns'
|
||||||
import { ru } from 'date-fns/locale'
|
import { ru } from 'date-fns/locale'
|
||||||
|
import { CalendarDays, Clock } from 'lucide-react'
|
||||||
|
|
||||||
import { PanelCard } from '@/components/panel-card'
|
import { IllustratedEmptyState } from '@/components/patterns/illustrated-empty-state'
|
||||||
|
import { PanelCard, panelCardContentFlushClassName } from '@/components/panel-card'
|
||||||
import { StatusBadge } from '@/components/status-badge'
|
import { StatusBadge } from '@/components/status-badge'
|
||||||
import { Calendar } from '@evobgp/ui/components/calendar'
|
import { Item } from '@evobgp/ui/components/item'
|
||||||
import { ScrollArea } from '@evobgp/ui/components/scroll-area'
|
import { ScrollArea } from '@evobgp/ui/components/scroll-area'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@evobgp/ui/components/select'
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
import { jobKindRu } from '@/lib/ui-labels'
|
import { jobKindRu } from '@/lib/ui-labels'
|
||||||
import type { JobRow } from '@/types/api'
|
import type { JobRow } from '@/types/api'
|
||||||
|
|
||||||
/** schedule-1 inspired agenda: calendar + day job list. */
|
import { ScheduleCalendarView } from './schedule-calendar-view'
|
||||||
|
|
||||||
|
type JobFilter = 'all' | 'refresh' | 'failed'
|
||||||
|
|
||||||
|
const FILTER_ITEMS: { value: JobFilter; label: string }[] = [
|
||||||
|
{ value: 'all', label: 'Все задачи' },
|
||||||
|
{ value: 'refresh', label: 'Обновление' },
|
||||||
|
{ value: 'failed', label: 'С ошибкой' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function jobTimestamp(job: JobRow): string | undefined {
|
||||||
|
return job.created_at ?? job.started_at ?? job.finished_at ?? undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesFilter(job: JobRow, filter: JobFilter): boolean {
|
||||||
|
if (filter === 'refresh') return job.kind === 'module_refresh'
|
||||||
|
if (filter === 'failed')
|
||||||
|
return ['failed', 'error', 'cancelled'].includes(job.status.toLowerCase())
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScheduleJobCard({ job }: { job: JobRow }) {
|
||||||
|
const ts = jobTimestamp(job)
|
||||||
|
const timeLabel = ts
|
||||||
|
? format(parseISO(ts), 'd MMM · HH:mm', { locale: ru })
|
||||||
|
: '—'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Item variant="outline" size="xs" className="flex items-start gap-3 py-3">
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||||
|
<p className="text-foreground text-sm leading-tight font-medium">{jobKindRu(job.kind)}</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||||
|
<StatusBadge status={job.status} />
|
||||||
|
<span className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||||
|
<Clock className="size-3 shrink-0" aria-hidden />
|
||||||
|
{timeLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-muted-foreground font-mono text-xs">{job.job_id}</p>
|
||||||
|
</div>
|
||||||
|
</Item>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** schedule-1 layout: calendar column + day job list. */
|
||||||
export function ScheduleAgendaPanel({
|
export function ScheduleAgendaPanel({
|
||||||
jobs,
|
jobs,
|
||||||
isLoading,
|
isLoading,
|
||||||
@@ -18,25 +72,12 @@ export function ScheduleAgendaPanel({
|
|||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
}) {
|
}) {
|
||||||
const [date, setDate] = useState<Date>(new Date())
|
const [date, setDate] = useState<Date>(new Date())
|
||||||
|
const [filter, setFilter] = useState<JobFilter>('all')
|
||||||
const dayJobs = useMemo(
|
|
||||||
() =>
|
|
||||||
jobs.filter((job) => {
|
|
||||||
const raw = job.created_at ?? job.started_at ?? job.finished_at
|
|
||||||
if (!raw) return false
|
|
||||||
try {
|
|
||||||
return isSameDay(parseISO(raw), date)
|
|
||||||
} catch {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
[jobs, date],
|
|
||||||
)
|
|
||||||
|
|
||||||
const markedDays = useMemo(() => {
|
const markedDays = useMemo(() => {
|
||||||
const days = new Set<string>()
|
const days = new Set<string>()
|
||||||
for (const job of jobs) {
|
for (const job of jobs) {
|
||||||
const raw = job.created_at ?? job.started_at ?? job.finished_at
|
const raw = jobTimestamp(job)
|
||||||
if (!raw) continue
|
if (!raw) continue
|
||||||
try {
|
try {
|
||||||
days.add(format(parseISO(raw), 'yyyy-MM-dd'))
|
days.add(format(parseISO(raw), 'yyyy-MM-dd'))
|
||||||
@@ -47,44 +88,85 @@ export function ScheduleAgendaPanel({
|
|||||||
return days
|
return days
|
||||||
}, [jobs])
|
}, [jobs])
|
||||||
|
|
||||||
|
const dayJobs = useMemo(
|
||||||
|
() =>
|
||||||
|
jobs.filter((job) => {
|
||||||
|
const raw = jobTimestamp(job)
|
||||||
|
if (!raw) return false
|
||||||
|
try {
|
||||||
|
return isSameDay(parseISO(raw), date) && matchesFilter(job, filter)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[jobs, date, filter],
|
||||||
|
)
|
||||||
|
|
||||||
|
const headingLabel = format(date, 'EEEE, d MMMM', { locale: ru })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PanelCard
|
<PanelCard
|
||||||
title="Календарь задач"
|
title="Календарь задач"
|
||||||
description="Задачи refresh и apply по дням (schedule-1 pattern)"
|
description="Задачи refresh и apply по дням"
|
||||||
className="h-full"
|
contentClassName={cn(panelCardContentFlushClassName, 'p-0')}
|
||||||
>
|
>
|
||||||
<div className="grid gap-4 p-4 lg:grid-cols-[minmax(0,280px)_1fr]">
|
<div className="flex flex-col lg:flex-row">
|
||||||
<Calendar
|
<div className="border-border shrink-0 border-b p-5 lg:w-[370px] lg:border-r lg:border-b-0">
|
||||||
mode="single"
|
<ScheduleCalendarView
|
||||||
selected={date}
|
selected={date}
|
||||||
onSelect={(d) => d && setDate(d)}
|
onSelect={(d) => d && setDate(d)}
|
||||||
locale={ru}
|
datesWithEvents={markedDays}
|
||||||
modifiers={{
|
/>
|
||||||
hasJob: (d) => markedDays.has(format(d, 'yyyy-MM-dd')),
|
</div>
|
||||||
}}
|
|
||||||
modifiersClassNames={{ hasJob: 'font-bold underline' }}
|
<div className="flex min-w-0 flex-1 flex-col gap-4 p-5">
|
||||||
/>
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||||
<ScrollArea className="h-64 lg:h-auto">
|
<div className="min-w-0">
|
||||||
{isLoading ? (
|
<h2 className="text-foreground text-sm font-semibold capitalize">{headingLabel}</h2>
|
||||||
<p className="text-muted-foreground text-sm">Загрузка…</p>
|
<p className="text-muted-foreground text-xs">
|
||||||
) : dayJobs.length === 0 ? (
|
{isLoading
|
||||||
<p className="text-muted-foreground text-sm">
|
? 'Загрузка…'
|
||||||
Нет задач за {format(date, 'd MMMM yyyy', { locale: ru })}
|
: dayJobs.length > 0
|
||||||
</p>
|
? `${dayJobs.length} ${dayJobs.length === 1 ? 'задача' : dayJobs.length < 5 ? 'задачи' : 'задач'}`
|
||||||
) : (
|
: 'Нет задач за выбранный день'}
|
||||||
<ul className="space-y-2 pr-3">
|
</p>
|
||||||
{dayJobs.map((job) => (
|
</div>
|
||||||
<li key={job.job_id} className="rounded-lg border px-3 py-2 text-sm">
|
<Select value={filter} onValueChange={(v) => v && setFilter(v as JobFilter)}>
|
||||||
<div className="flex items-center justify-between gap-2">
|
<SelectTrigger size="sm" className="w-full sm:w-44">
|
||||||
<span className="font-medium">{jobKindRu(job.kind)}</span>
|
<SelectValue />
|
||||||
<StatusBadge status={job.status} />
|
</SelectTrigger>
|
||||||
</div>
|
<SelectContent>
|
||||||
<p className="text-muted-foreground mt-1 text-xs">{job.job_id}</p>
|
{FILTER_ITEMS.map((item) => (
|
||||||
</li>
|
<SelectItem key={item.value} value={item.value}>
|
||||||
))}
|
{item.label}
|
||||||
</ul>
|
</SelectItem>
|
||||||
)}
|
))}
|
||||||
</ScrollArea>
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="min-h-[280px]">
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-muted-foreground text-sm">Загрузка задач…</p>
|
||||||
|
) : dayJobs.length === 0 ? (
|
||||||
|
<IllustratedEmptyState
|
||||||
|
icon={CalendarDays}
|
||||||
|
title="Нет задач"
|
||||||
|
description={`За ${format(date, 'd MMMM yyyy', { locale: ru })} задачи не найдены. Выберите другой день или измените фильтр.`}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="max-h-[320px] pr-3">
|
||||||
|
<ul className="space-y-2.5">
|
||||||
|
{dayJobs.map((job) => (
|
||||||
|
<li key={job.job_id}>
|
||||||
|
<ScheduleJobCard job={job} />
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PanelCard>
|
</PanelCard>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,181 @@
|
|||||||
|
import { useState, type ComponentPropsWithoutRef } from 'react'
|
||||||
|
import { DayButton } from 'react-day-picker'
|
||||||
|
import { format } from 'date-fns'
|
||||||
|
import { ru } from 'date-fns/locale'
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@evobgp/ui/lib/utils'
|
||||||
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
|
import { Calendar, CalendarDayButton } from '@evobgp/ui/components/calendar'
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@evobgp/ui/components/select'
|
||||||
|
|
||||||
|
const MONTHS_RU = Array.from({ length: 12 }, (_, i) =>
|
||||||
|
format(new Date(2024, i, 1), 'LLLL', { locale: ru }),
|
||||||
|
)
|
||||||
|
|
||||||
|
const CURRENT_YEAR = new Date().getFullYear()
|
||||||
|
const YEARS = Array.from({ length: 11 }, (_, i) => CURRENT_YEAR - 5 + i)
|
||||||
|
|
||||||
|
const TODAY_WEEKDAY = format(new Date(), 'EEEEEE', { locale: ru }).toUpperCase()
|
||||||
|
|
||||||
|
export function ScheduleCalendarView({
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
datesWithEvents = new Set<string>(),
|
||||||
|
}: {
|
||||||
|
selected: Date | undefined
|
||||||
|
onSelect: (date: Date | undefined) => void
|
||||||
|
datesWithEvents?: Set<string>
|
||||||
|
}) {
|
||||||
|
const [month, setMonth] = useState<Date>(selected ?? new Date())
|
||||||
|
|
||||||
|
const stepMonth = (delta: number) =>
|
||||||
|
setMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() + delta, 1))
|
||||||
|
|
||||||
|
const handleMonthSelect = (value: string) => {
|
||||||
|
const i = MONTHS_RU.indexOf(value)
|
||||||
|
if (i >= 0) setMonth(new Date(month.getFullYear(), i, 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleYearSelect = (value: string) => {
|
||||||
|
const y = parseInt(value, 10)
|
||||||
|
if (!Number.isNaN(y)) setMonth(new Date(y, month.getMonth(), 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-4 select-none">
|
||||||
|
<div className="flex w-full grow items-center justify-between gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="size-7 shrink-0 p-0"
|
||||||
|
onClick={() => stepMonth(-1)}
|
||||||
|
aria-label="Предыдущий месяц"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="size-3.5" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={MONTHS_RU[month.getMonth()]}
|
||||||
|
onValueChange={(value) => value && handleMonthSelect(value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger size="sm" className="min-w-0 flex-1 capitalize">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{MONTHS_RU.map((m) => (
|
||||||
|
<SelectItem key={m} value={m} className="capitalize">
|
||||||
|
{m}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Select
|
||||||
|
value={String(month.getFullYear())}
|
||||||
|
onValueChange={(value) => value && handleYearSelect(value)}
|
||||||
|
>
|
||||||
|
<SelectTrigger size="sm" className="w-22 shrink-0">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{YEARS.map((y) => (
|
||||||
|
<SelectItem key={y} value={String(y)}>
|
||||||
|
{y}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="size-7 shrink-0 p-0"
|
||||||
|
onClick={() => stepMonth(1)}
|
||||||
|
aria-label="Следующий месяц"
|
||||||
|
>
|
||||||
|
<ChevronRight className="size-3.5" aria-hidden />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Calendar
|
||||||
|
mode="single"
|
||||||
|
selected={selected}
|
||||||
|
onSelect={onSelect}
|
||||||
|
month={month}
|
||||||
|
onMonthChange={setMonth}
|
||||||
|
locale={ru}
|
||||||
|
showOutsideDays
|
||||||
|
hideNavigation
|
||||||
|
className="w-full bg-transparent p-0 md:[--cell-size:--spacing(11)]"
|
||||||
|
formatters={{
|
||||||
|
formatWeekdayName: (date) =>
|
||||||
|
date.toLocaleString('ru-RU', { weekday: 'short' }).replace('.', '').toUpperCase(),
|
||||||
|
}}
|
||||||
|
classNames={{
|
||||||
|
month_caption: 'hidden',
|
||||||
|
nav: 'hidden',
|
||||||
|
weekdays: 'flex gap-1',
|
||||||
|
weekday:
|
||||||
|
'flex-1 flex items-center justify-center h-6 text-[0.65rem] font-medium text-muted-foreground',
|
||||||
|
week: 'flex gap-1 mt-1',
|
||||||
|
day: 'flex-1 aspect-square p-0',
|
||||||
|
day_button: cn(
|
||||||
|
'bg-muted/50 hover:bg-muted rounded-md',
|
||||||
|
'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[selected-single=true]:hover:bg-primary data-[selected-single=true]:hover:text-primary-foreground!',
|
||||||
|
),
|
||||||
|
outside: 'opacity-60',
|
||||||
|
disabled: 'opacity-60',
|
||||||
|
today: cn('bg-accent text-foreground rounded-md'),
|
||||||
|
}}
|
||||||
|
components={{
|
||||||
|
Weekday: ({ children, className: cls, ...props }: ComponentPropsWithoutRef<'th'>) => {
|
||||||
|
const isToday = children === TODAY_WEEKDAY
|
||||||
|
return (
|
||||||
|
<th
|
||||||
|
scope="col"
|
||||||
|
className={cn(
|
||||||
|
'flex h-6! flex-1 items-center justify-center rounded-md text-xs font-medium',
|
||||||
|
isToday ? 'bg-accent text-foreground!' : 'text-muted-foreground',
|
||||||
|
cls,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</th>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
DayButton: ({
|
||||||
|
children,
|
||||||
|
modifiers,
|
||||||
|
day,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DayButton>) => {
|
||||||
|
const dateKey = format(day.date, 'yyyy-MM-dd')
|
||||||
|
const hasEvents = !modifiers.outside && datesWithEvents.has(dateKey)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CalendarDayButton day={day} modifiers={modifiers} {...props}>
|
||||||
|
{hasEvents ? (
|
||||||
|
<span
|
||||||
|
className="bg-primary text-primary-foreground in-data-[selected-single=true]:bg-primary-foreground! size-1 rounded-full"
|
||||||
|
aria-hidden
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="size-1" aria-hidden />
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</CalendarDayButton>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
|
import { DataGridCard } from '@/components/data-grid-shell'
|
||||||
|
import { QueryState } from '@/components/query-state'
|
||||||
|
import { TableSkeleton } from '@/components/skeletons'
|
||||||
|
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||||
|
import type { JobRow } from '@/types/api'
|
||||||
|
|
||||||
|
import { ScheduleJobsGrid } from './schedule-jobs-grid'
|
||||||
|
|
||||||
|
type JobTab = 'all' | 'refresh' | 'failed'
|
||||||
|
|
||||||
|
function filterJobs(items: JobRow[], tab: JobTab): JobRow[] {
|
||||||
|
if (tab === 'refresh') return items.filter((j) => j.kind === 'module_refresh')
|
||||||
|
if (tab === 'failed')
|
||||||
|
return items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
function tabCounts(items: JobRow[]) {
|
||||||
|
return {
|
||||||
|
all: items.length,
|
||||||
|
refresh: items.filter((j) => j.kind === 'module_refresh').length,
|
||||||
|
failed: items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
||||||
|
.length,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Jobs data-grid with status tabs (data-grid-filtering pattern). */
|
||||||
|
export function ScheduleJobsCard({
|
||||||
|
jobs,
|
||||||
|
isLoading,
|
||||||
|
isError,
|
||||||
|
error,
|
||||||
|
onRetry,
|
||||||
|
}: {
|
||||||
|
jobs: JobRow[]
|
||||||
|
isLoading: boolean
|
||||||
|
isError: boolean
|
||||||
|
error: unknown
|
||||||
|
onRetry: () => void
|
||||||
|
}) {
|
||||||
|
const [tab, setTab] = useState<JobTab>('all')
|
||||||
|
const counts = useMemo(() => tabCounts(jobs), [jobs])
|
||||||
|
const filtered = useMemo(() => filterJobs(jobs, tab), [jobs, tab])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DataGridCard title="Задачи" description="Последние задачи из API">
|
||||||
|
<div className="px-5 pt-3">
|
||||||
|
<Tabs value={tab} onValueChange={(v) => setTab(v as JobTab)} className="w-full">
|
||||||
|
<TabsList variant="line" className="w-full justify-start gap-6">
|
||||||
|
<TabsTrigger value="all">Все ({counts.all})</TabsTrigger>
|
||||||
|
<TabsTrigger value="refresh">Обновление ({counts.refresh})</TabsTrigger>
|
||||||
|
<TabsTrigger value="failed">С ошибкой ({counts.failed})</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
<QueryState
|
||||||
|
data={filtered}
|
||||||
|
isLoading={isLoading}
|
||||||
|
isError={isError}
|
||||||
|
error={error}
|
||||||
|
empty={filtered.length === 0}
|
||||||
|
emptyTitle="Нет задач в выборке"
|
||||||
|
skeleton={<TableSkeleton rows={6} cols={5} />}
|
||||||
|
onRetry={onRetry}
|
||||||
|
>
|
||||||
|
{(items) => (
|
||||||
|
<ScheduleJobsGrid
|
||||||
|
items={items}
|
||||||
|
isLoading={isLoading && items.length > 0}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</QueryState>
|
||||||
|
</DataGridCard>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -90,6 +90,7 @@ export function ScheduleJobsGrid({
|
|||||||
recordCount={filteredCount}
|
recordCount={filteredCount}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
emptyMessage="Нет задач"
|
emptyMessage="Нет задач"
|
||||||
|
showPagination={items.length > 10}
|
||||||
searchValue={globalFilter}
|
searchValue={globalFilter}
|
||||||
onSearchChange={setGlobalFilter}
|
onSearchChange={setGlobalFilter}
|
||||||
searchPlaceholder="Поиск задач…"
|
searchPlaceholder="Поиск задач…"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -22,7 +22,7 @@ export function SettingsSettingField({
|
|||||||
contentClassName,
|
contentClassName,
|
||||||
}: {
|
}: {
|
||||||
title: string
|
title: string
|
||||||
description: string
|
description: ReactNode
|
||||||
badge?: { label: string; variant: ComponentProps<typeof Badge>['variant'] }
|
badge?: { label: string; variant: ComponentProps<typeof Badge>['variant'] }
|
||||||
children: ReactNode
|
children: ReactNode
|
||||||
last?: boolean
|
last?: boolean
|
||||||
|
|||||||
@@ -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,34 +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,
|
||||||
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={kpiGridClassName}>
|
<KpiStatGridSkeleton count={6} />
|
||||||
{Array.from({ length: 4 }).map((_, i) => (
|
|
||||||
<Skeleton key={`kpi-${i}`} className="h-36 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" />
|
||||||
@@ -49,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,303 @@
|
|||||||
|
/**
|
||||||
|
* 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
|
||||||
|
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('/firewall')) return 'bgp:firewall: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',
|
||||||
|
'/firewall',
|
||||||
|
'/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,23 +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'
|
||||||
|
|
||||||
/** Grid for dashboard-5 style KPI sparkline row (4 columns at xl). */
|
/** @deprecated Use kpiStatGridClassName — kept for legacy imports. */
|
||||||
|
export const dashboardKpiGridClassName = kpiStatGridClassName
|
||||||
|
|
||||||
|
/** @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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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'
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ import { Skeleton } from '@evobgp/ui/components/skeleton'
|
|||||||
|
|
||||||
import { DashboardActivityTimeline } from '@/components/dashboard/dashboard-activity-timeline'
|
import { DashboardActivityTimeline } from '@/components/dashboard/dashboard-activity-timeline'
|
||||||
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
|
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
|
||||||
import { DashboardKpiSparklineRow } from '@/components/dashboard/dashboard-kpi-sparkline-row'
|
import { DashboardKpiGrid } from '@/components/dashboard/dashboard-kpi-grid'
|
||||||
import { DashboardModulesGrid } from '@/components/dashboard/dashboard-modules-grid'
|
import { DashboardModulesGrid } from '@/components/dashboard/dashboard-modules-grid'
|
||||||
import { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health'
|
import { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health'
|
||||||
import { DashboardOperationsBreakdown } from '@/components/dashboard/dashboard-operations-breakdown'
|
import { DashboardOperationsBreakdown } from '@/components/dashboard/dashboard-operations-breakdown'
|
||||||
@@ -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,19 +94,19 @@ function DashboardComponent() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<DashboardQuickLinks />
|
|
||||||
|
|
||||||
{initialLoading ? (
|
{initialLoading ? (
|
||||||
<AnalyticsDashboardSkeleton />
|
<AnalyticsDashboardSkeleton />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<DashboardKpiSparklineRow
|
<DashboardKpiGrid
|
||||||
modules={modules}
|
modules={modules}
|
||||||
peers={peers}
|
peers={peers}
|
||||||
speakers={speakers}
|
speakers={speakers}
|
||||||
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>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { toast } from 'sonner'
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
import { Button } from '@evobgp/ui/components/button'
|
||||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
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 { ScheduleJobsGrid } from '@/components/schedule/schedule-jobs-grid'
|
import { ScheduleJobsCard } from '@/components/schedule/schedule-jobs-card'
|
||||||
import { ScheduleModulesGrid } from '@/components/schedule/schedule-modules-grid'
|
import { ScheduleModulesGrid } from '@/components/schedule/schedule-modules-grid'
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { QueryState } from '@/components/query-state'
|
import { QueryState } from '@/components/query-state'
|
||||||
@@ -18,7 +18,6 @@ import { SectionCardsSkeleton } from '@/components/skeletons'
|
|||||||
import { operationsJobsQueryOptions } from '@/queries/operations'
|
import { operationsJobsQueryOptions } from '@/queries/operations'
|
||||||
import { modulesListQueryOptions } from '@/queries/modules'
|
import { modulesListQueryOptions } from '@/queries/modules'
|
||||||
import { apiMutate } from '@/lib/api-client'
|
import { apiMutate } from '@/lib/api-client'
|
||||||
import type { JobRow } from '@/types/api'
|
|
||||||
|
|
||||||
export const Route = createFileRoute('/_auth/schedule')({
|
export const Route = createFileRoute('/_auth/schedule')({
|
||||||
component: ScheduleComponent,
|
component: ScheduleComponent,
|
||||||
@@ -39,14 +38,36 @@ function ScheduleComponent() {
|
|||||||
).length
|
).length
|
||||||
|
|
||||||
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>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -115,43 +136,13 @@ function ScheduleComponent() {
|
|||||||
</QueryState>
|
</QueryState>
|
||||||
</DataGridCard>
|
</DataGridCard>
|
||||||
|
|
||||||
<DataGridCard title="Задачи" description="Последние задачи из API">
|
<ScheduleJobsCard
|
||||||
<JobsTabs jobs={jobs} loading={jobsQ.isLoading} />
|
jobs={jobs}
|
||||||
</DataGridCard>
|
isLoading={jobsQ.isLoading}
|
||||||
|
isError={jobsQ.isError}
|
||||||
|
error={jobsQ.error}
|
||||||
|
onRetry={() => jobsQ.refetch()}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function JobsTabs({ jobs, loading }: { jobs: JobRow[]; loading: boolean }) {
|
|
||||||
const refresh = jobs.filter((j) => j.kind === 'module_refresh')
|
|
||||||
const failed = jobs.filter((j) =>
|
|
||||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
|
||||||
)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<BadgeTabs
|
|
||||||
defaultValue="all"
|
|
||||||
listClassName="mx-3 mb-0 w-auto"
|
|
||||||
items={[
|
|
||||||
{ value: 'all', label: 'Все', count: jobs.length },
|
|
||||||
{ value: 'refresh', label: 'Обновление', count: refresh.length, badgeVariant: 'info-light' },
|
|
||||||
{
|
|
||||||
value: 'failed',
|
|
||||||
label: 'С ошибкой',
|
|
||||||
count: failed.length,
|
|
||||||
badgeVariant: failed.length > 0 ? 'destructive-light' : 'primary-light',
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<TabsContent value="all" className="mt-0">
|
|
||||||
<ScheduleJobsGrid items={jobs} isLoading={loading} />
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="refresh" className="mt-0">
|
|
||||||
<ScheduleJobsGrid items={refresh} isLoading={loading} />
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="failed" className="mt-0">
|
|
||||||
<ScheduleJobsGrid items={failed} isLoading={loading} />
|
|
||||||
</TabsContent>
|
|
||||||
</BadgeTabs>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,143 +1,51 @@
|
|||||||
import { createFileRoute, useNavigate, useRouterState } from '@tanstack/react-router'
|
import { createFileRoute } from '@tanstack/react-router'
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
import { z } from 'zod'
|
||||||
|
|
||||||
import { Button } from '@evobgp/ui/components/button'
|
|
||||||
import { Alert, AlertDescription } from '@evobgp/ui/components/alert'
|
|
||||||
import { PanelCard } from '@/components/panel-card'
|
|
||||||
import { Input } from '@evobgp/ui/components/input'
|
|
||||||
import { Label } from '@evobgp/ui/components/label'
|
|
||||||
|
|
||||||
import { PageHeader } from '@/components/page-header'
|
import { PageHeader } from '@/components/page-header'
|
||||||
import { LoadingButton } from '@/components/loading-button'
|
import { SettingsPageShell } from '@/components/settings/settings-page-shell'
|
||||||
import { SelectField } from '@/components/select-field'
|
import {
|
||||||
import { DEV_API_TOKEN, normalizeApiToken, setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
type SettingsTab,
|
||||||
import { authKeys, authSessionQueryOptions } from '@/queries/auth'
|
} from '@/components/settings/settings-tabs-data'
|
||||||
import { toast } from 'sonner'
|
|
||||||
import { Save } from 'lucide-react'
|
const settingsSearchSchema = z.object({
|
||||||
import { useTheme } from 'next-themes'
|
tab: z
|
||||||
import { useEffect, useState } from 'react'
|
.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_SELECT_ITEMS = [
|
|
||||||
{ value: 'light', label: 'Светлая' },
|
|
||||||
{ value: 'dark', label: 'Тёмная' },
|
|
||||||
{ value: 'system', label: 'Как в системе' },
|
|
||||||
] 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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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-токен. Сохраните токен ниже или нажмите «Использовать dev».
|
tokenRequired={tokenRequired}
|
||||||
</AlertDescription>
|
/>
|
||||||
</Alert>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<PanelCard
|
|
||||||
title="Подключение к API"
|
|
||||||
description="Токен хранится только в этом браузере (localStorage). Управление ключами tenant — в разделе «Права доступа»."
|
|
||||||
contentClassName="flex flex-col gap-4 py-4"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label htmlFor="token">Токен для запросов</Label>
|
|
||||||
<Input
|
|
||||||
id="token"
|
|
||||||
type="password"
|
|
||||||
autoComplete="off"
|
|
||||||
value={token}
|
|
||||||
onChange={(e) => setTokenValue(e.target.value)}
|
|
||||||
placeholder="dev или API-ключ"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
<LoadingButton onClick={saveTokenHandler}>
|
|
||||||
<Save />
|
|
||||||
Сохранить токен
|
|
||||||
</LoadingButton>
|
|
||||||
<Button type="button" variant="outline" onClick={useDevToken}>
|
|
||||||
Использовать dev
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{session ? (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Активная сессия: tenant <code className="font-mono">{session.tenant_id}</code>, роль{' '}
|
|
||||||
<code className="font-mono">{session.role}</code>.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
{sessionError ? (
|
|
||||||
<p className="text-xs text-destructive">
|
|
||||||
{sessionQueryError instanceof Error
|
|
||||||
? sessionQueryError.message
|
|
||||||
: 'Не удалось проверить сессию'}
|
|
||||||
. Для токена <code className="font-mono">dev</code> нужен demo-seed (
|
|
||||||
<code className="text-xs">EVOBGP_SEED_DEMO</code> ≠ 0) и запущенный API.
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
</PanelCard>
|
|
||||||
|
|
||||||
<PanelCard
|
|
||||||
title="Оформление"
|
|
||||||
description="Тема интерфейса. Быстрый переключатель также доступен в боковой панели."
|
|
||||||
contentClassName="flex flex-col gap-2 py-4"
|
|
||||||
>
|
|
||||||
<SelectField
|
|
||||||
id="theme-select"
|
|
||||||
label="Тема"
|
|
||||||
items={[...THEME_SELECT_ITEMS]}
|
|
||||||
value={theme ?? 'system'}
|
|
||||||
placeholder="Выберите тему"
|
|
||||||
triggerClassName="max-w-xs"
|
|
||||||
onValueChange={(v) => v && setTheme(v)}
|
|
||||||
/>
|
|
||||||
</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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
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 == "" {
|
||||||
|
|||||||
@@ -2,6 +2,24 @@
|
|||||||
|
|
||||||
Как выдавать доступ к 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` | Tenant для всех portal JWT (обязателен при JWT) |
|
||||||
|
|
||||||
|
Права — строки `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
|
||||||
|
|
||||||
|
|||||||
+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 из `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
|
||||||
@@ -14,6 +14,7 @@ require (
|
|||||||
github.com/beorn7/perks v1.0.1 // indirect
|
github.com/beorn7/perks v1.0.1 // indirect
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
|||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
|
||||||
|
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
|
||||||
|
|||||||
+128
-7
@@ -6,18 +6,32 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"evobgp/internal/authkey"
|
"evobgp/internal/authkey"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ctxKey int
|
type ctxKey int
|
||||||
|
|
||||||
const authCtxKey ctxKey = 1
|
const authCtxKey ctxKey = 1
|
||||||
|
|
||||||
|
// Auth kinds distinguish API key sessions from portal JWT sessions.
|
||||||
|
const (
|
||||||
|
AuthKindAPIKey = "apikey"
|
||||||
|
AuthKindJWT = "jwt"
|
||||||
|
)
|
||||||
|
|
||||||
// Auth holds resolved API identity for a request.
|
// Auth holds resolved API identity for a request.
|
||||||
type Auth struct {
|
type Auth struct {
|
||||||
TenantID string
|
TenantID string
|
||||||
Role string // viewer, editor, operator, node
|
Role string // viewer, editor, operator, node, firewall (apikeys only)
|
||||||
Token string
|
Token string
|
||||||
APIKeyID string // non-empty for DB-managed keys
|
APIKeyID string // non-empty for DB-managed keys
|
||||||
|
// Portal / dual-auth fields (empty for API keys unless noted).
|
||||||
|
Kind string // "apikey" | "jwt"
|
||||||
|
UserID string // JWT sub
|
||||||
|
Email string // JWT email claim
|
||||||
|
Permissions []string // JWT permissions claim (bgp:*)
|
||||||
|
IsAdmin bool // JWT is_admin claim
|
||||||
}
|
}
|
||||||
|
|
||||||
func authFromContext(ctx context.Context) (Auth, bool) {
|
func authFromContext(ctx context.Context) (Auth, bool) {
|
||||||
@@ -56,6 +70,23 @@ func parseAPIKeysSpec(spec string) []apiKeyRecord {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// looksLikeJWT reports whether raw is a compact JWS (three dot-separated segments, non-empty).
|
||||||
|
func looksLikeJWT(raw string) bool {
|
||||||
|
if raw == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
parts := strings.Split(raw, ".")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, p := range parts {
|
||||||
|
if strings.TrimSpace(p) == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
h := r.Header.Get("Authorization")
|
h := r.Header.Get("Authorization")
|
||||||
@@ -65,6 +96,16 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
raw := strings.TrimSpace(strings.TrimPrefix(h, p))
|
raw := strings.TrimSpace(strings.TrimPrefix(h, p))
|
||||||
|
if looksLikeJWT(raw) && strings.TrimSpace(s.jwtSecret) != "" {
|
||||||
|
a, status, msg, ok := s.resolveJWT(raw)
|
||||||
|
if !ok {
|
||||||
|
writeProblem(w, status, http.StatusText(status), msg)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
r = r.WithContext(context.WithValue(r.Context(), authCtxKey, a))
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
a, ok := s.resolveAuth(raw)
|
a, ok := s.resolveAuth(raw)
|
||||||
if !ok {
|
if !ok {
|
||||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "unknown api key")
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "unknown api key")
|
||||||
@@ -79,10 +120,16 @@ func (s *Server) authMiddleware(next http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func authFromKeyRecord(raw string, rec apiKeyRecord) Auth {
|
func authFromKeyRecord(raw string, rec apiKeyRecord) Auth {
|
||||||
return Auth{TenantID: rec.tenantID, Role: rec.role, Token: raw, APIKeyID: rec.keyID}
|
return Auth{
|
||||||
|
Kind: AuthKindAPIKey,
|
||||||
|
TenantID: rec.tenantID,
|
||||||
|
Role: rec.role,
|
||||||
|
Token: raw,
|
||||||
|
APIKeyID: rec.keyID,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// resolveAuth maps a bearer token to tenant identity.
|
// resolveAuth maps a bearer token to tenant identity (API key path).
|
||||||
// For the literal token "dev", the demo shortcut (devAuth) takes precedence when demo-seed
|
// For the literal token "dev", the demo shortcut (devAuth) takes precedence when demo-seed
|
||||||
// is available; env/DB mapping is used only when demo tenant is absent.
|
// is available; env/DB mapping is used only when demo tenant is absent.
|
||||||
func (s *Server) resolveAuth(raw string) (Auth, bool) {
|
func (s *Server) resolveAuth(raw string) (Auth, bool) {
|
||||||
@@ -99,23 +146,96 @@ func (s *Server) resolveAuth(raw string) (Auth, bool) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
if s.firewallResolver != nil {
|
if s.firewallResolver != nil {
|
||||||
if fw, ok := s.firewallResolver.Lookup(raw); ok {
|
if fw, ok := s.firewallResolver.Lookup(raw); ok {
|
||||||
return Auth{TenantID: fw.tenantID, Role: "firewall", Token: raw, APIKeyID: fw.clientID}, true
|
return Auth{Kind: AuthKindAPIKey, TenantID: fw.tenantID, Role: "firewall", Token: raw, APIKeyID: fw.clientID}, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if client, err := s.store.LookupFirewallClientByTokenHash(authkey.HashToken(raw)); err == nil {
|
if client, err := s.store.LookupFirewallClientByTokenHash(authkey.HashToken(raw)); err == nil {
|
||||||
return Auth{TenantID: client.TenantID, Role: "firewall", Token: raw, APIKeyID: client.ID}, true
|
return Auth{Kind: AuthKindAPIKey, TenantID: client.TenantID, Role: "firewall", Token: raw, APIKeyID: client.ID}, true
|
||||||
}
|
}
|
||||||
return Auth{}, false
|
return Auth{}, false
|
||||||
}
|
}
|
||||||
return authFromKeyRecord(raw, rec), true
|
return authFromKeyRecord(raw, rec), true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveJWT parses and validates a portal HS256 token, returning an Auth on success.
|
||||||
|
// Returns (auth, status, detail, ok). status/detail are used when ok=false.
|
||||||
|
func (s *Server) resolveJWT(raw string) (Auth, int, string, bool) {
|
||||||
|
if strings.TrimSpace(s.portalTenantID) == "" {
|
||||||
|
return Auth{}, http.StatusServiceUnavailable, "portal tenant not configured (EVOBGP_PORTAL_TENANT_ID)", false
|
||||||
|
}
|
||||||
|
tok, err := jwt.Parse(raw, func(t *jwt.Token) (any, error) {
|
||||||
|
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||||
|
return nil, jwt.ErrSignatureInvalid
|
||||||
|
}
|
||||||
|
return []byte(s.jwtSecret), nil
|
||||||
|
}, jwt.WithValidMethods([]string{jwt.SigningMethodHS256.Alg()}))
|
||||||
|
if err != nil || tok == nil || !tok.Valid {
|
||||||
|
return Auth{}, http.StatusUnauthorized, "invalid jwt", false
|
||||||
|
}
|
||||||
|
claims, ok := tok.Claims.(jwt.MapClaims)
|
||||||
|
if !ok {
|
||||||
|
return Auth{}, http.StatusUnauthorized, "invalid jwt claims", false
|
||||||
|
}
|
||||||
|
if iss := strings.TrimSpace(s.authIssuer); iss != "" {
|
||||||
|
got, _ := claims["iss"].(string)
|
||||||
|
if strings.TrimSpace(got) != iss {
|
||||||
|
return Auth{}, http.StatusUnauthorized, "jwt issuer mismatch", false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
apps := coerceStringSlice(claims["apps"])
|
||||||
|
if !containsFold(apps, "bgp") {
|
||||||
|
return Auth{}, http.StatusForbidden, "jwt does not grant access to bgp app", false
|
||||||
|
}
|
||||||
|
sub, _ := claims["sub"].(string)
|
||||||
|
if strings.TrimSpace(sub) == "" {
|
||||||
|
return Auth{}, http.StatusUnauthorized, "jwt missing sub", false
|
||||||
|
}
|
||||||
|
email, _ := claims["email"].(string)
|
||||||
|
perms := coerceStringSlice(claims["permissions"])
|
||||||
|
isAdmin, _ := claims["is_admin"].(bool)
|
||||||
|
return Auth{
|
||||||
|
Kind: AuthKindJWT,
|
||||||
|
TenantID: s.portalTenantID,
|
||||||
|
UserID: strings.TrimSpace(sub),
|
||||||
|
Email: strings.TrimSpace(email),
|
||||||
|
Permissions: perms,
|
||||||
|
IsAdmin: isAdmin,
|
||||||
|
Token: raw,
|
||||||
|
}, 0, "", true
|
||||||
|
}
|
||||||
|
|
||||||
|
func coerceStringSlice(v any) []string {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case []string:
|
||||||
|
return t
|
||||||
|
case []any:
|
||||||
|
out := make([]string, 0, len(t))
|
||||||
|
for _, x := range t {
|
||||||
|
if s, ok := x.(string); ok {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func containsFold(items []string, needle string) bool {
|
||||||
|
for _, x := range items {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(x), needle) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) devAuth() (Auth, bool) {
|
func (s *Server) devAuth() (Auth, bool) {
|
||||||
tid, _, _, _, _ := s.store.DemoIDs()
|
tid, _, _, _, _ := s.store.DemoIDs()
|
||||||
if tid == "" {
|
if tid == "" {
|
||||||
return Auth{}, false
|
return Auth{}, false
|
||||||
}
|
}
|
||||||
return Auth{TenantID: tid, Role: "operator", Token: "dev"}, true
|
return Auth{Kind: AuthKindAPIKey, TenantID: tid, Role: "operator", Token: "dev"}, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func roleLevel(role string) int {
|
func roleLevel(role string) int {
|
||||||
@@ -131,7 +251,8 @@ func roleLevel(role string) int {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// requireAtLeast rejects node role and enforces viewer/editor/operator ladder.
|
// requireAtLeast rejects node/firewall roles and enforces viewer/editor/operator ladder for API keys.
|
||||||
|
// New code should call requirePerm which supports JWT permissions in addition to API-key roles.
|
||||||
func (s *Server) requireAtLeast(w http.ResponseWriter, a Auth, need string) bool {
|
func (s *Server) requireAtLeast(w http.ResponseWriter, a Auth, need string) bool {
|
||||||
if strings.ToLower(a.Role) == "node" {
|
if strings.ToLower(a.Role) == "node" {
|
||||||
writeProblem(w, http.StatusForbidden, "Forbidden", "node role cannot access this resource")
|
writeProblem(w, http.StatusForbidden, "Forbidden", "node role cannot access this resource")
|
||||||
|
|||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
testJWTSecret = "test-secret-32-bytes-long-abcdef"
|
||||||
|
testIssuer = "https://auth.test.local"
|
||||||
|
)
|
||||||
|
|
||||||
|
func signTestJWT(t *testing.T, claims jwt.MapClaims) string {
|
||||||
|
t.Helper()
|
||||||
|
tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||||
|
s, err := tok.SignedString([]byte(testJWTSecret))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sign jwt: %v", err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func newJWTTestServer(t *testing.T) (*Server, string) {
|
||||||
|
t.Helper()
|
||||||
|
srv, err := New(Options{
|
||||||
|
SeedDemo: true,
|
||||||
|
BundleSeedHex: testBundleSeed,
|
||||||
|
JWTSecret: testJWTSecret,
|
||||||
|
AuthIssuer: testIssuer,
|
||||||
|
AuthPortalURL: "https://portal.test.local",
|
||||||
|
AuthRequired: true,
|
||||||
|
PortalTenantID: "", // filled after DemoIDs
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
tenant, _, _, _, _ := srv.Store().DemoIDs()
|
||||||
|
// Override tenant to match seed.
|
||||||
|
srv.portalTenantID = tenant
|
||||||
|
return srv, tenant
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthJWTAcceptedWithBGPApp(t *testing.T) {
|
||||||
|
srv, _ := newJWTTestServer(t)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ts := httptest.NewServer(srv.Handler())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
token := signTestJWT(t, jwt.MapClaims{
|
||||||
|
"iss": testIssuer,
|
||||||
|
"sub": "user-1",
|
||||||
|
"email": "[email protected]",
|
||||||
|
"apps": []string{"bgp"},
|
||||||
|
"permissions": []string{"bgp:modules:read"},
|
||||||
|
"exp": time.Now().Add(time.Hour).Unix(),
|
||||||
|
})
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := ts.Client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("status=%d body=%s", resp.StatusCode, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthJWTRejectedOnWrongIssuer(t *testing.T) {
|
||||||
|
srv, _ := newJWTTestServer(t)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ts := httptest.NewServer(srv.Handler())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
token := signTestJWT(t, jwt.MapClaims{
|
||||||
|
"iss": "https://other.example.com",
|
||||||
|
"sub": "user-1",
|
||||||
|
"apps": []string{"bgp"},
|
||||||
|
"exp": time.Now().Add(time.Hour).Unix(),
|
||||||
|
})
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := ts.Client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Fatalf("status=%d want 401", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthJWTRejectedWhenBGPAppMissing(t *testing.T) {
|
||||||
|
srv, _ := newJWTTestServer(t)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ts := httptest.NewServer(srv.Handler())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
token := signTestJWT(t, jwt.MapClaims{
|
||||||
|
"iss": testIssuer,
|
||||||
|
"sub": "user-1",
|
||||||
|
"apps": []string{"cfdm", "portal"},
|
||||||
|
"exp": time.Now().Add(time.Hour).Unix(),
|
||||||
|
})
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/modules", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := ts.Client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Fatalf("status=%d want 403", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthJWTIsAdminBypassesPermissions(t *testing.T) {
|
||||||
|
srv, _ := newJWTTestServer(t)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ts := httptest.NewServer(srv.Handler())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
token := signTestJWT(t, jwt.MapClaims{
|
||||||
|
"iss": testIssuer,
|
||||||
|
"sub": "admin-1",
|
||||||
|
"apps": []string{"bgp"},
|
||||||
|
"is_admin": true,
|
||||||
|
"exp": time.Now().Add(time.Hour).Unix(),
|
||||||
|
})
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/api-keys", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := ts.Client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("status=%d body=%s", resp.StatusCode, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthJWTMissingPermissionRejected(t *testing.T) {
|
||||||
|
srv, _ := newJWTTestServer(t)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ts := httptest.NewServer(srv.Handler())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
token := signTestJWT(t, jwt.MapClaims{
|
||||||
|
"iss": testIssuer,
|
||||||
|
"sub": "user-1",
|
||||||
|
"apps": []string{"bgp"},
|
||||||
|
"permissions": []string{"bgp:modules:read"},
|
||||||
|
"exp": time.Now().Add(time.Hour).Unix(),
|
||||||
|
})
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/api-keys", nil)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+token)
|
||||||
|
resp, err := ts.Client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Fatalf("status=%d want 403", resp.StatusCode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthConfigPublic(t *testing.T) {
|
||||||
|
srv, _ := newJWTTestServer(t)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
ts := httptest.NewServer(srv.Handler())
|
||||||
|
defer ts.Close()
|
||||||
|
|
||||||
|
req, _ := http.NewRequest(http.MethodGet, ts.URL+"/v1/auth/config", nil)
|
||||||
|
resp, err := ts.Client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Fatalf("status=%d body=%s", resp.StatusCode, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHasPermissionSupersets(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
granted []string
|
||||||
|
want string
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"exact-read", []string{"bgp:modules:read"}, "bgp:modules:read", true},
|
||||||
|
{"write-covers-read", []string{"bgp:modules:write"}, "bgp:modules:read", true},
|
||||||
|
{"admin-covers-write", []string{"bgp:modules:admin"}, "bgp:modules:write", true},
|
||||||
|
{"read-does-not-cover-write", []string{"bgp:modules:read"}, "bgp:modules:write", false},
|
||||||
|
{"different-section", []string{"bgp:network:admin"}, "bgp:modules:read", false},
|
||||||
|
{"empty-granted", nil, "bgp:modules:read", false},
|
||||||
|
{"malformed-required", []string{"bgp:modules:admin"}, "bgp:modules", false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
if got := HasPermission(tc.granted, tc.want); got != tc.ok {
|
||||||
|
t.Fatalf("HasPermission(%v, %q) = %v, want %v", tc.granted, tc.want, got, tc.ok)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
package httpapi
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Portal permission strings. Format: <app>:<section>:<level> (bgp:modules:read).
|
||||||
|
// Superset order: admin ⊃ write ⊃ read for the same <app>:<section>.
|
||||||
|
const (
|
||||||
|
permLevelRead = "read"
|
||||||
|
permLevelWrite = "write"
|
||||||
|
permLevelAdmin = "admin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// permLevelRank returns 0 for unknown, 1 for read, 2 for write, 3 for admin.
|
||||||
|
func permLevelRank(level string) int {
|
||||||
|
switch strings.ToLower(strings.TrimSpace(level)) {
|
||||||
|
case permLevelRead:
|
||||||
|
return 1
|
||||||
|
case permLevelWrite:
|
||||||
|
return 2
|
||||||
|
case permLevelAdmin:
|
||||||
|
return 3
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitPerm splits a permission string into (app, section, level).
|
||||||
|
func splitPerm(perm string) (app, section, level string, ok bool) {
|
||||||
|
parts := strings.Split(strings.TrimSpace(perm), ":")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return "", "", "", false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(parts[0]), strings.TrimSpace(parts[1]), strings.TrimSpace(parts[2]), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasPermission reports whether the granted list satisfies required, applying the
|
||||||
|
// admin ⊃ write ⊃ read superset within the same app+section.
|
||||||
|
func HasPermission(granted []string, required string) bool {
|
||||||
|
rApp, rSection, rLevel, ok := splitPerm(required)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
needRank := permLevelRank(rLevel)
|
||||||
|
if needRank == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, g := range granted {
|
||||||
|
gApp, gSection, gLevel, ok := splitPerm(g)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !strings.EqualFold(gApp, rApp) || !strings.EqualFold(gSection, rSection) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if permLevelRank(gLevel) >= needRank {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// permAPIKeyRoleFor maps a permission level to the API-key role required.
|
||||||
|
func permAPIKeyRoleFor(perm string) string {
|
||||||
|
_, _, level, ok := splitPerm(perm)
|
||||||
|
if !ok {
|
||||||
|
return "operator"
|
||||||
|
}
|
||||||
|
switch strings.ToLower(level) {
|
||||||
|
case permLevelRead:
|
||||||
|
return "viewer"
|
||||||
|
case permLevelWrite:
|
||||||
|
return "editor"
|
||||||
|
case permLevelAdmin:
|
||||||
|
return "operator"
|
||||||
|
default:
|
||||||
|
return "operator"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// requirePerm enforces a permission for a portal JWT or falls back to the API-key role ladder.
|
||||||
|
// node/firewall roles are always rejected (they use requireNode / requireFirewall).
|
||||||
|
func (s *Server) requirePerm(w http.ResponseWriter, a Auth, perm string) bool {
|
||||||
|
switch strings.ToLower(a.Role) {
|
||||||
|
case "node":
|
||||||
|
writeProblem(w, http.StatusForbidden, "Forbidden", "node role cannot access this resource")
|
||||||
|
return false
|
||||||
|
case "firewall":
|
||||||
|
writeProblem(w, http.StatusForbidden, "Forbidden", "firewall role cannot access this resource")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if a.Kind == AuthKindJWT || len(a.Permissions) > 0 {
|
||||||
|
if a.IsAdmin || HasPermission(a.Permissions, perm) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
writeProblem(w, http.StatusForbidden, "Forbidden", "missing permission: "+perm)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
need := permAPIKeyRoleFor(perm)
|
||||||
|
if roleLevel(a.Role) < roleLevel(need) {
|
||||||
|
writeProblem(w, http.StatusForbidden, "Forbidden", "insufficient role")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
+40
-41
@@ -35,6 +35,7 @@ func (s *Server) Handler() http.Handler {
|
|||||||
s.mux.HandleFunc("GET /v1/health", s.handleHealth)
|
s.mux.HandleFunc("GET /v1/health", s.handleHealth)
|
||||||
s.mux.HandleFunc("GET /v1/ready", s.handleReady)
|
s.mux.HandleFunc("GET /v1/ready", s.handleReady)
|
||||||
s.mux.HandleFunc("GET /v1/version", s.handleVersion)
|
s.mux.HandleFunc("GET /v1/version", s.handleVersion)
|
||||||
|
s.mux.HandleFunc("GET /v1/auth/config", s.handleAuthConfigPublic)
|
||||||
s.mux.HandleFunc("POST /v1/firewall/enroll", s.handleFirewallEnrollPublic)
|
s.mux.HandleFunc("POST /v1/firewall/enroll", s.handleFirewallEnrollPublic)
|
||||||
s.mux.HandleFunc("GET /v1/firewall/install.sh", s.handleFirewallInstallScript)
|
s.mux.HandleFunc("GET /v1/firewall/install.sh", s.handleFirewallInstallScript)
|
||||||
s.mux.HandleFunc("GET /v1/firewall/sync-script", s.handleFirewallSyncScript)
|
s.mux.HandleFunc("GET /v1/firewall/sync-script", s.handleFirewallSyncScript)
|
||||||
@@ -54,6 +55,7 @@ func (s *Server) registerRoutes() {
|
|||||||
|
|
||||||
func (s *Server) registerV1(m *http.ServeMux) {
|
func (s *Server) registerV1(m *http.ServeMux) {
|
||||||
m.HandleFunc("GET /modules", s.handleListModules)
|
m.HandleFunc("GET /modules", s.handleListModules)
|
||||||
|
m.HandleFunc("GET /lookup", s.handleLookup)
|
||||||
m.HandleFunc("GET /router-lists/catalog", s.handleRouterListsCatalog)
|
m.HandleFunc("GET /router-lists/catalog", s.handleRouterListsCatalog)
|
||||||
m.HandleFunc("GET /modules/{module_id}", s.handleGetModule)
|
m.HandleFunc("GET /modules/{module_id}", s.handleGetModule)
|
||||||
m.HandleFunc("GET /peers", s.handleListPeers)
|
m.HandleFunc("GET /peers", s.handleListPeers)
|
||||||
@@ -92,6 +94,15 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleAuthConfigPublic exposes portal-auth wiring so the UI can decide whether to redirect to the login portal.
|
||||||
|
// Registered on the public mux (no auth middleware): safe to call without a bearer token.
|
||||||
|
func (s *Server) handleAuthConfigPublic(w http.ResponseWriter, r *http.Request) {
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"required": s.authRequired,
|
||||||
|
"portal_url": s.authPortalURL,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleReady(w http.ResponseWriter, r *http.Request) {
|
||||||
checks := map[string]string{"store": "ok", "jobs": "memory"}
|
checks := map[string]string{"store": "ok", "jobs": "memory"}
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
|
||||||
@@ -187,7 +198,7 @@ func (s *Server) handleListModules(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.requireAtLeast(w, a, "viewer") {
|
if !s.requirePerm(w, a, "bgp:modules:read") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
typeFilter := strings.TrimSpace(r.URL.Query().Get("type"))
|
typeFilter := strings.TrimSpace(r.URL.Query().Get("type"))
|
||||||
@@ -203,23 +214,9 @@ func (s *Server) handleListModules(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
filtered := make([]*store.Module, 0)
|
filtered := make([]*store.Module, 0)
|
||||||
limit := parseListLimit(r)
|
all := s.store.ListModules(a.TenantID)
|
||||||
cursor := r.URL.Query().Get("cursor")
|
all = store.FilterOwned(all, func(m *store.Module) string { return m.CreatedByUserID }, a.Kind, a.IsAdmin, a.UserID)
|
||||||
if typeFilter == "" && enabledFilter == nil {
|
for _, mod := range all {
|
||||||
page, next, more := s.store.ListModulesPage(a.TenantID, cursor, limit)
|
|
||||||
for _, mod := range page {
|
|
||||||
filtered = append(filtered, mod)
|
|
||||||
}
|
|
||||||
items := make([]map[string]any, 0, len(filtered))
|
|
||||||
for _, mod := range filtered {
|
|
||||||
items = append(items, moduleJSON(mod))
|
|
||||||
}
|
|
||||||
writeJSON(w, http.StatusOK, map[string]any{
|
|
||||||
"items": items, "next_cursor": strPtrOrNull(next), "has_more": more,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for _, mod := range s.store.ListModules(a.TenantID) {
|
|
||||||
if typeFilter != "" && mod.Type != typeFilter {
|
if typeFilter != "" && mod.Type != typeFilter {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -244,7 +241,7 @@ func (s *Server) handleRouterListsCatalog(w http.ResponseWriter, r *http.Request
|
|||||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.requireAtLeast(w, a, "viewer") {
|
if !s.requirePerm(w, a, "bgp:directories:read") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
cat, err := reports.BuildRouterListsCatalog(s.store, a.TenantID)
|
cat, err := reports.BuildRouterListsCatalog(s.store, a.TenantID)
|
||||||
@@ -267,10 +264,14 @@ func (s *Server) handleGetModule(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.requireAtLeast(w, a, "viewer") {
|
if !s.requirePerm(w, a, "bgp:modules:read") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
mod, err := s.store.GetModule(a.TenantID, r.PathValue("module_id"))
|
mod, err := s.store.GetModule(a.TenantID, r.PathValue("module_id"))
|
||||||
|
if err == nil && !store.CanAccessOwned(a.Kind, a.IsAdmin, a.UserID, mod.CreatedByUserID) {
|
||||||
|
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
|
||||||
|
return
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == store.ErrNotFound || err == store.ErrTenantScope {
|
if err == store.ErrNotFound || err == store.ErrTenantScope {
|
||||||
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
|
writeProblem(w, http.StatusNotFound, "Not Found", "module not found")
|
||||||
@@ -288,10 +289,11 @@ func (s *Server) handleListPeers(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.requireAtLeast(w, a, "viewer") {
|
if !s.requirePerm(w, a, "bgp:network:read") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
allPeers := s.store.ListPeers(a.TenantID)
|
allPeers := s.store.ListPeers(a.TenantID)
|
||||||
|
allPeers = store.FilterOwned(allPeers, func(p *store.BGPPeer) string { return p.CreatedByUserID }, a.Kind, a.IsAdmin, a.UserID)
|
||||||
page, next, more := store.PaginateOffset(allPeers, r.URL.Query().Get("cursor"), parseListLimit(r))
|
page, next, more := store.PaginateOffset(allPeers, r.URL.Query().Get("cursor"), parseListLimit(r))
|
||||||
fresh := r != nil && strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("live")), "1")
|
fresh := r != nil && strings.EqualFold(strings.TrimSpace(r.URL.Query().Get("live")), "1")
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
|
||||||
@@ -391,7 +393,7 @@ func (s *Server) handleListSpeakers(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.requireAtLeast(w, a, "viewer") {
|
if !s.requirePerm(w, a, "bgp:network:read") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
speakers := s.store.ListSpeakersForTenant(a.TenantID)
|
speakers := s.store.ListSpeakersForTenant(a.TenantID)
|
||||||
@@ -423,7 +425,7 @@ func (s *Server) handleModuleRefresh(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.requireAtLeast(w, a, "editor") {
|
if !s.requirePerm(w, a, "bgp:modules:write") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
moduleID := r.PathValue("module_id")
|
moduleID := r.PathValue("module_id")
|
||||||
@@ -459,7 +461,7 @@ func (s *Server) handleTenantRefresh(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.requireAtLeast(w, a, "editor") {
|
if !s.requirePerm(w, a, "bgp:modules:write") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var body struct {
|
var body struct {
|
||||||
@@ -513,7 +515,7 @@ func (s *Server) handleListRevisions(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.requireAtLeast(w, a, "viewer") {
|
if !s.requirePerm(w, a, "bgp:operations:read") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||||
@@ -586,7 +588,7 @@ func (s *Server) handleGetRevision(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.requireAtLeast(w, a, "viewer") {
|
if !s.requirePerm(w, a, "bgp:operations:read") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rev, err := s.store.GetRevisionSummary(a.TenantID, r.PathValue("revision_id"))
|
rev, err := s.store.GetRevisionSummary(a.TenantID, r.PathValue("revision_id"))
|
||||||
@@ -603,7 +605,7 @@ func (s *Server) handleRevisionPreview(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.requireAtLeast(w, a, "viewer") {
|
if !s.requirePerm(w, a, "bgp:operations:read") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rev, err := s.store.GetRevision(a.TenantID, r.PathValue("revision_id"))
|
rev, err := s.store.GetRevision(a.TenantID, r.PathValue("revision_id"))
|
||||||
@@ -642,7 +644,7 @@ func (s *Server) handleRevisionDiagnosticLog(w http.ResponseWriter, r *http.Requ
|
|||||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.requireAtLeast(w, a, "viewer") {
|
if !s.requirePerm(w, a, "bgp:operations:read") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
revID := r.PathValue("revision_id")
|
revID := r.PathValue("revision_id")
|
||||||
@@ -668,7 +670,7 @@ func (s *Server) handleRevisionDiff(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.requireAtLeast(w, a, "viewer") {
|
if !s.requirePerm(w, a, "bgp:operations:read") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
d, err := s.store.RevisionDiff(a.TenantID, r.PathValue("revision_a"), r.PathValue("revision_b"))
|
d, err := s.store.RevisionDiff(a.TenantID, r.PathValue("revision_a"), r.PathValue("revision_b"))
|
||||||
@@ -685,7 +687,7 @@ func (s *Server) handleRevisionRollback(w http.ResponseWriter, r *http.Request)
|
|||||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.requireAtLeast(w, a, "operator") {
|
if !s.requirePerm(w, a, "bgp:operations:admin") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
revID := r.PathValue("revision_id")
|
revID := r.PathValue("revision_id")
|
||||||
@@ -716,8 +718,7 @@ func (s *Server) handleApply(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.ToLower(a.Role) != "operator" {
|
if !s.requirePerm(w, a, "bgp:operations:admin") {
|
||||||
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var body struct {
|
var body struct {
|
||||||
@@ -763,8 +764,7 @@ func (s *Server) handleSpeakerApply(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.ToLower(a.Role) != "operator" {
|
if !s.requirePerm(w, a, "bgp:operations:admin") {
|
||||||
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
spkID := r.PathValue("id")
|
spkID := r.PathValue("id")
|
||||||
@@ -814,8 +814,7 @@ func (s *Server) handleBirdReload(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if strings.ToLower(a.Role) != "operator" {
|
if !s.requirePerm(w, a, "bgp:operations:admin") {
|
||||||
writeProblem(w, http.StatusForbidden, "Forbidden", "operator role required")
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
idem := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
|
idem := strings.TrimSpace(r.Header.Get("Idempotency-Key"))
|
||||||
@@ -838,7 +837,7 @@ func (s *Server) handleBirdStatus(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.requireAtLeast(w, a, "viewer") {
|
if !s.requirePerm(w, a, "bgp:monitoring:read") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
|
ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
|
||||||
@@ -853,7 +852,7 @@ func (s *Server) handleListJobs(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.requireAtLeast(w, a, "viewer") {
|
if !s.requirePerm(w, a, "bgp:operations:read") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
||||||
@@ -876,7 +875,7 @@ func (s *Server) handleGetJob(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.requireAtLeast(w, a, "viewer") {
|
if !s.requirePerm(w, a, "bgp:operations:read") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
j, err := s.jobs.Get(a.TenantID, r.PathValue("job_id"))
|
j, err := s.jobs.Get(a.TenantID, r.PathValue("job_id"))
|
||||||
@@ -893,7 +892,7 @@ func (s *Server) handleGetJobReport(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.requireAtLeast(w, a, "viewer") {
|
if !s.requirePerm(w, a, "bgp:operations:read") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
j, err := s.jobs.Get(a.TenantID, r.PathValue("job_id"))
|
j, err := s.jobs.Get(a.TenantID, r.PathValue("job_id"))
|
||||||
@@ -931,7 +930,7 @@ func (s *Server) handleCancelJob(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
writeProblem(w, http.StatusUnauthorized, "Unauthorized", "missing auth")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if !s.requireAtLeast(w, a, "editor") {
|
if !s.requirePerm(w, a, "bgp:operations:admin") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
j, err := s.jobs.RequestCancel(a.TenantID, r.PathValue("job_id"))
|
j, err := s.jobs.RequestCancel(a.TenantID, r.PathValue("job_id"))
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user