Compare commits

..
7 Commits
Author SHA1 Message Date
Denozordec 1639ba40f3 refactor: update dashboard components to integrate QuickActionGrid and enhance settings
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Successful in 33s
CI / web (push) Successful in 1m0s
CI / go (push) Successful in 56s
CI / bird2 (push) Successful in 18s
CI / release (push) Successful in 4m14s
Removed the deprecated DashboardQuickLinkCard and replaced it with QuickActionGrid in the DashboardQuickLinks component for improved organization and user experience. Updated the AppearanceSettingsTab to include a toggle for displaying quick actions on the dashboard, enhancing user customization options. Adjusted the SystemMonitorPopover layout for better alignment and spacing. Updated documentation to reflect the new UI preferences for quick actions.
2026-07-17 20:34:25 +07:00
Denozordec 26a96bc824 refactor: update KPI components for improved layout and clarity
CI / changes (push) Successful in 7s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Successful in 47s
CI / go (push) Successful in 52s
CI / bird2 (push) Successful in 15s
CI / release (push) Successful in 4m21s
Revised the KpiStatGrid and KpiStatCardBody components to enhance the layout and presentation of KPI tiles. Updated the documentation to reflect the new horizontal compact hybrid design, ensuring better visual organization with the icon on the left and improved label and value alignment. Adjusted the skeleton loading state for a more cohesive user experience.
2026-07-17 18:37:00 +07:00
Denozordec 4fc5c96e63 refactor: enhance KPI components with variant support and improved layout
CI / changes (push) Successful in 6s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Successful in 49s
CI / go (push) Successful in 51s
CI / bird2 (push) Successful in 16s
CI / release (push) Successful in 4m12s
Updated the KPI components to include a new KpiStatVariant type for better visual differentiation. Refactored the KpiStatCardBody and KpiStatGrid to utilize this variant, allowing for dynamic styling based on the KPI state. Improved the layout of the KpiStatGrid and its associated components for a more cohesive user experience, including adjustments to the Badge footer presentation.
2026-07-17 17:15:14 +07:00
Denozordec 3859983ae5 refactor: update UI components to utilize Frame for improved layout and consistency
CI / changes (push) Successful in 7s
CI / commitlint (push) Skipped
CI / openapi (push) Skipped
CI / web (push) Successful in 1m7s
CI / go (push) Successful in 2m11s
CI / bird2 (push) Successful in 17s
CI / release (push) Successful in 3m46s
Refactored multiple components, including PanelCard, SettingsCard, and DataGridShell, to replace Card with Frame for better organization and presentation. Enhanced the DataGridToolbar to support optional ReUI filters and improved the overall structure of the KPI stat grid. Updated .gitignore to include .env.local for local environment configurations, ensuring better management of environment variables.
2026-07-17 15:30:41 +07:00
Denozordec aa1779170a refactor: enhance settings and auth components for improved navigation and user feedback
CI / changes (push) Successful in 10s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 54s
CI / go (push) Successful in 1m3s
CI / bird2 (push) Successful in 20s
CI / release (push) Successful in 4m29s
Updated the Settings and Auth components to include better navigation handling by adding search parameters for tab selection. Enhanced the Access component to utilize badges for status indicators, improving visual clarity. Refactored the Schedule component to streamline job status display with badges, ensuring a more cohesive user experience. Additionally, integrated client directives in UI components for better performance.
2026-07-09 23:39:10 +07:00
Denozordec bbcf9d76d9 refactor: enhance schedule components with improved filtering and layout
CI / changes (push) Successful in 11s
CI / openapi (push) Has been skipped
CI / commitlint (push) Has been skipped
CI / web (push) Successful in 51s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m20s
Updated the ScheduleAgendaPanel to include a job filter feature, allowing users to filter jobs by type (all, refresh, failed). Refactored the layout to integrate a new ScheduleCalendarView for better organization. Enhanced the ScheduleJobsGrid to conditionally display pagination based on the number of items. Additionally, modified the Schedule component to utilize the new ScheduleJobsCard for improved job display and loading states, ensuring a more cohesive user experience.
2026-07-09 23:10:20 +07:00
Denozordec 55eb2a6c89 refactor: enhance dashboard layout and settings components for improved user experience
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m7s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m20s
Updated the AnalyticsDashboardSkeleton to utilize a new grid layout for KPI tiles, increasing the number of displayed items and adjusting their size for better visibility. Modified the SettingsSettingField component to accept a ReactNode for the description, allowing for richer content. Refactored the Settings component to improve the organization of API token management and session status display, enhancing clarity and usability. Additionally, integrated new UI elements for theme selection and improved layout consistency across components.
2026-07-09 21:58:39 +07:00
75 changed files with 4985 additions and 613 deletions
+2 -2
View File
@@ -1,6 +1,6 @@
{
"pid": 43636,
"pid": 51184,
"version": "0.9.9",
"socketPath": "\\\\.\\pipe\\codegraph-97b92efdcc5351da",
"startedAt": 1783570299043
"startedAt": 1784215907952
}
+8
View File
@@ -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}
+6 -3
View File
@@ -22,9 +22,12 @@ alwaysApply: false
|------|------|------------|
| shadcn-примитивы | `packages/ui/src/components/` | output `shadcn add` (не трогать под кейс) |
| 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) |
**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>`.
## Правила
@@ -45,8 +48,8 @@ alwaysApply: false
**WEB-05** | MUST | Формы — `react-hook-form` + Zod; через `FormField`/`Form` обёртки.
*Проверка:* https://ui.shadcn.com/docs/components/form
**WEB-06** | MUST | Сложные data-списки — ReUI `DataGridCard` (ReUI data-grid, не shadcn Data Table). Простые списки — shadcn `Table`.
*Проверка:* `@/components/reui/data-grid` или `@evobgp/ui/components/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-kit`, `@/components/reui/data-grid`, `docs/ui-design-contract.md`.
**WEB-07** | MUST | Toast — `sonner` (`Toaster` в `main.tsx`); `toast.success/error/message` из `sonner`.
*Проверка:* https://ui.shadcn.com/docs/components/sonner
+1
View File
@@ -21,6 +21,7 @@ Thumbs.db
# Env / secrets (local only)
.env
.env.local
.env.*
!.env.example
!.env.*.example
+3 -1
View File
@@ -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/` (архив, только референс при миграции).
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 стека.
@@ -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'
export type AnalyticsKpiItem = {
@@ -12,39 +12,42 @@ export type AnalyticsKpiItem = {
}
}
const TONE_CLASS = {
success: 'text-success',
warning: 'text-warning',
destructive: 'text-destructive',
muted: 'text-muted-foreground',
} as const
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" />
function deltaBadgeVariant(
tone?: 'success' | 'warning' | 'destructive' | 'muted',
): 'success-light' | 'warning-light' | 'destructive-light' | 'outline' {
if (tone === 'success') return 'success-light'
if (tone === 'warning') return 'warning-light'
if (tone === 'destructive') return 'destructive-light'
return 'outline'
}
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 (
<div className={cn('grid gap-4 sm:grid-cols-3', className)}>
{items.map((item) => (
<div key={item.label} className="min-w-0 space-y-1">
<p className="text-xs text-muted-foreground">{item.label}</p>
<p className="text-2xl font-semibold tracking-tight tabular-nums">{item.value}</p>
{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>
<KpiStatGrid
items={items.map(toKpiItem)}
embedded
className={cn(className)}
aria-label="Показатели"
/>
)
}
+2 -4
View File
@@ -40,14 +40,12 @@ export function BadgeTabs({
value={value}
defaultValue={defaultValue}
onValueChange={onValueChange}
orientation="horizontal"
className={cn('w-full', className)}
>
<TabsList
variant="line"
className={cn(
'mb-4 w-full justify-start gap-6',
listClassName,
)}
className={cn('mb-3.5 w-full justify-start gap-6', listClassName)}
>
{items.map((item) => (
<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="billing@acme.dev"
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="alex@acme.dev"
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,67 @@
import { Gauge, Network, Play, Plus, Share2, Tags } from 'lucide-react'
import type { ReactNode } from 'react'
import { DashboardQuickLinkCard } from '@/components/dashboard/dashboard-quick-link-card'
import { PanelCard } from '@/components/panel-card'
import { QuickActionGrid, type QuickActionItem } from '@/components/reui-kit'
type QuickLink = {
icon: ReactNode
label: string
description: string
to: string
search?: Record<string, string>
iconClass: string
}
const LINKS: QuickLink[] = [
const ACTIONS: QuickActionItem[] = [
{
icon: <Plus aria-hidden />,
label: 'Создать модуль',
id: 'new-module',
title: 'Создать модуль',
description: 'Новый модуль маршрутизации и источники префиксов.',
to: '/modules/new',
iconClass: 'bg-primary text-primary-foreground [&_svg]:text-primary-foreground',
icon: <Plus aria-hidden />,
iconClassName: 'bg-primary text-primary-foreground [&_svg]:text-primary-foreground',
},
{
icon: <Tags aria-hidden />,
label: 'BGP-сообщества',
id: 'communities',
title: 'BGP-сообщества',
description: 'Справочник communities для политик экспорта.',
to: '/directories',
iconClass: 'bg-info text-info-foreground [&_svg]:text-info-foreground',
icon: <Tags aria-hidden />,
iconClassName: 'bg-info text-info-foreground [&_svg]:text-info-foreground',
},
{
icon: <Network aria-hidden />,
label: 'Сеть',
id: 'network',
title: 'Сеть',
description: 'Обзор пиров, спикеров и live-сессий BGP.',
to: '/network',
search: { tab: 'overview' },
iconClass: 'bg-success text-success-foreground [&_svg]:text-success-foreground',
icon: <Network aria-hidden />,
iconClassName: 'bg-success text-success-foreground [&_svg]:text-success-foreground',
},
{
icon: <Share2 aria-hidden />,
label: 'Добавить пира',
id: 'add-peer',
title: 'Добавить пира',
description: 'Настройка BGP-соседа и шаблонов сессии.',
to: '/network',
search: { tab: 'peers' },
iconClass: 'bg-warning text-warning-foreground [&_svg]:text-warning-foreground',
icon: <Share2 aria-hidden />,
iconClassName: 'bg-warning text-warning-foreground [&_svg]:text-warning-foreground',
},
{
icon: <Play aria-hidden />,
label: 'Деплой',
id: 'deploy',
title: 'Деплой',
description: 'Ревизии конфигурации и применение на нодах.',
to: '/operations',
search: { tab: 'revisions' },
iconClass: 'bg-focus text-focus-foreground [&_svg]:text-focus-foreground',
icon: <Play aria-hidden />,
iconClassName: 'bg-focus text-focus-foreground [&_svg]:text-focus-foreground',
},
{
icon: <Gauge aria-hidden />,
label: 'Мониторинг',
id: 'monitoring',
title: 'Мониторинг',
description: 'Состояние системы, BIRD и PostgreSQL.',
to: '/monitoring',
search: { tab: 'system' },
iconClass: 'bg-destructive text-destructive-foreground [&_svg]:text-destructive-foreground',
icon: <Gauge aria-hidden />,
iconClassName: 'bg-destructive text-destructive-foreground [&_svg]:text-destructive-foreground',
},
]
export function DashboardQuickLinks() {
return (
<PanelCard
title="Быстрые действия"
<QuickActionGrid
actions={ACTIONS}
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>
/>
)
}
+8 -3
View File
@@ -4,10 +4,15 @@ import type { Table } from '@tanstack/react-table'
import { cn } from '@evobgp/ui/lib/utils'
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 { DataGrid, DataGridContainer } from '@/components/reui/data-grid/data-grid'
import { DataGridTable } from '@/components/reui/data-grid/data-grid-table'
import { FrameFooter } from '@/components/reui/frame'
import {
DATA_GRID_MESSAGES_RU,
DATA_GRID_PAGINATION_RU,
@@ -52,9 +57,9 @@ export function DataGridShell<TData extends object>({
<DataGridTable />
</DataGridContainer>
{showPagination ? (
<div className={cn(panelCardFooterClassName, panelCardInsetClassName)}>
<FrameFooter className={cn(panelCardFooterClassName)}>
<DataGridPagination {...DATA_GRID_PAGINATION_RU} />
</div>
</FrameFooter>
) : null}
</DataGrid>
)
@@ -16,12 +16,13 @@ interface DataGridToolbarProps {
searchValue: string
onSearchChange: (value: string) => void
searchPlaceholder?: string
/** ReUI Filters trigger or custom filter controls. Preview: https://reui.io/preview/base/data-grid-filtering-2 */
filters?: ReactNode
actions?: ReactNode
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({
searchValue,
onSearchChange,
@@ -58,19 +59,19 @@ export function DataGridToolbar({
filters
) : (
<InputGroupButton
aria-label="Фильтры"
size="icon-xs"
variant="ghost"
size="xs"
className="pointer-events-none gap-1.5 opacity-0"
aria-hidden="true"
tabIndex={-1}
disabled
title="Передайте filters={<Filters … />} из @/components/reui/filters"
>
<ListFilterIcon className="size-3.5" />
<ListFilterIcon aria-hidden="true" />
</InputGroupButton>
)}
</InputGroupAddon>
</InputGroup>
</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>
)
}
@@ -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="alex.chen@example.com"
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>
)
}
+14
View File
@@ -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'
+1 -1
View File
@@ -92,7 +92,7 @@ const NAV_GROUPS: NavGroup[] = [
items: [
{ to: '/access', label: 'Доступ', icon: KeyRound, description: 'API-ключи' },
{ 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' } },
],
},
]
@@ -181,7 +181,7 @@ export function SystemMonitorPopover() {
</Badge>
</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">
<span className="text-foreground text-xs font-medium">Монитор EvoBGP</span>
<span className="text-muted-foreground text-[11px] tabular-nums">
@@ -1,5 +1,7 @@
import { KpiSparklineCard, type KpiSparklineMetric } from '@/components/patterns/kpi-sparkline-card'
import { kpiGridClassName } from '@/lib/ui-surface'
import { Clock, Gauge, Globe, Tags } from 'lucide-react'
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
import { Badge } from '@/components/reui/badge'
import { formatDateTime, moduleIntervalLabel } from '@/lib/modules/display'
import {
communityLabel,
@@ -7,7 +9,7 @@ import {
moduleDohProfileIds,
} from '@/lib/modules/helpers'
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'
interface ModuleKpiCardsProps {
@@ -18,12 +20,6 @@ interface ModuleKpiCardsProps {
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({
mod,
communities,
@@ -32,67 +28,65 @@ export function ModuleKpiCards({
loading = false,
}: ModuleKpiCardsProps) {
if (loading || !mod) {
return <SectionCardsSkeleton count={4} />
return <KpiStatGridSkeleton count={4} />
}
const asPrefixTotal = asEntries.reduce((acc, entry) => acc + (entry.prefix_count ?? 0), 0)
const dohIds = moduleDohProfileIds(mod)
const metrics: KpiSparklineMetric[] = [
const items: KpiStatItem[] = [
{
id: 'priority',
title: 'Приоритет',
label: 'Порядок в ревизии',
icon: <Gauge aria-hidden />,
iconClassName: 'text-info',
value: String(mod.priority ?? 0),
delta: mod.enabled !== false ? 'активен' : 'выключен',
deltaVariant: mod.enabled !== false ? 'success-light' : 'outline',
detail: mod.type,
tone: 'info',
sparkline: syntheticSparkline(mod.priority ?? 1),
label: 'Приоритет',
footer: (
<Badge variant={mod.enabled !== false ? 'success-light' : 'outline'} size="sm">
{mod.enabled !== false ? 'активен' : 'выключен'} · {mod.type}
</Badge>
),
},
{
id: 'interval',
title: 'Интервал',
label: 'Обновление',
icon: <Clock aria-hidden />,
iconClassName: 'text-warning',
value: moduleIntervalLabel(mod),
delta: formatDateTime(mod.last_refreshed_at) || 'никогда',
deltaVariant: 'primary-light',
detail: 'last refresh',
tone: 'warning',
sparkline: syntheticSparkline(12),
label: 'Интервал обновления',
footer: (
<Badge variant="primary-light" size="sm">
{formatDateTime(mod.last_refreshed_at) || 'никогда'}
</Badge>
),
},
{
id: 'prefixes',
title: 'Префиксы',
label: mod.type === 'AS_PREFIXES' ? 'AS entries' : 'Записи',
icon: <Tags aria-hidden />,
iconClassName: 'text-success',
value: mod.type === 'AS_PREFIXES' ? String(asPrefixTotal) : String(asEntries.length),
delta: `${asEntries.length} AS`,
deltaVariant: 'success-light',
detail: 'в модуле',
tone: 'success',
sparkline: syntheticSparkline(asPrefixTotal || asEntries.length || 1),
label: mod.type === 'AS_PREFIXES' ? 'Префиксы AS' : 'Записи модуля',
footer: (
<Badge variant="success-light" size="sm">
{asEntries.length} AS · в модуле
</Badge>
),
},
{
id: 'policy',
title: 'DoH / BGP',
label: communityLabel(mod.default_community_id, communities),
icon: <Globe aria-hidden />,
iconClassName: 'text-primary',
value: dohIds.length > 0 ? String(dohIds.length) : '—',
delta: dohPolicyRu(mod.doh_resolver_policy),
deltaVariant: 'info-light',
detail:
dohIds.length > 0
? dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join(', ')
: 'без DoH',
tone: 'info',
sparkline: syntheticSparkline(dohIds.length || 2),
label: communityLabel(mod.default_community_id, communities),
footer: (
<Badge variant="info-light" size="sm">
{dohPolicyRu(mod.doh_resolver_policy)}
{dohIds.length > 0
? ` · ${dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join(', ')}`
: ' · без DoH'}
</Badge>
),
},
]
return (
<section aria-label="KPI модуля" className={kpiGridClassName}>
{metrics.map((metric) => (
<KpiSparklineCard key={metric.id} metric={metric} />
))}
</section>
)
return <KpiStatGrid items={items} aria-label="KPI модуля" />
}
@@ -65,9 +65,9 @@ export function NetworkPeersCard({
</Button>
}
>
<div className="border-b px-5 py-3">
<Tabs value={tab} onValueChange={(v) => setTab(v as PeerTab)}>
<TabsList>
<div className="px-5 pt-3">
<Tabs value={tab} onValueChange={(v) => setTab(v as PeerTab)} className="w-full">
<TabsList variant="line" className="w-full justify-start gap-6">
<TabsTrigger value="all">Все ({counts.all})</TabsTrigger>
<TabsTrigger value="established">Established ({counts.established})</TabsTrigger>
<TabsTrigger value="pending">Ожидание ({counts.pending})</TabsTrigger>
@@ -52,9 +52,9 @@ export function OperationsJobsCard({
return (
<DataGridCard title="Задачи" description="Фильтр по статусу · data-grid-filtering pattern">
<div className="border-b px-5 py-3">
<Tabs value={tab} onValueChange={(v) => setTab(v as JobTab)}>
<TabsList>
<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="active">Активные ({counts.active})</TabsTrigger>
<TabsTrigger value="succeeded">Успешные ({counts.succeeded})</TabsTrigger>
+53 -27
View File
@@ -1,21 +1,24 @@
import type { ReactNode } from 'react'
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@evobgp/ui/components/card'
Frame,
FrameDescription,
FrameFooter,
FrameHeader,
FramePanel,
FrameTitle,
} from '@/components/reui/frame'
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 panelCardContentFlushClassName = 'px-0'
/** Horizontal inset for toolbar / pagination rows (matches edgeCell px-5). */
/** Flush body inside FramePanel (toolbar / data-grid). */
export const panelCardContentFlushClassName = 'p-0'
/** Horizontal inset for toolbar / pagination rows. */
export const panelCardInsetClassName = 'px-5 py-3'
export const panelCardFooterClassName = 'border-t bg-transparent'
@@ -45,22 +48,45 @@ export function PanelCard({
size = 'default',
}: PanelCardProps) {
const hasHeader = Boolean(title || description || actions)
const spacing = size === 'sm' ? 'sm' : 'default'
return (
<Card size={size} className={cn(panelCardClassName, className)}>
{hasHeader ? (
<CardHeader className={cn(panelCardHeaderClassName, headerClassName)}>
{title ? <CardTitle>{title}</CardTitle> : null}
{description ? <CardDescription>{description}</CardDescription> : null}
{actions ? <CardAction>{actions}</CardAction> : null}
</CardHeader>
) : null}
{children != null && children !== false ? (
<CardContent className={contentClassName}>{children}</CardContent>
) : null}
{footer ? (
<CardFooter className={cn(panelCardFooterClassName, footerClassName)}>{footer}</CardFooter>
) : null}
</Card>
<Frame
dense
spacing={spacing}
className={cn(panelCardClassName, className)}
>
<FramePanel className="flex flex-col gap-0 p-0 shadow-xs">
{hasHeader ? (
<FrameHeader
className={cn(
panelCardHeaderClassName,
'flex-row items-start justify-between gap-3',
headerClassName,
)}
>
<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 { IllustratedEmptyState } from './illustrated-empty-state'
export { KpiSparklineCard, type KpiSparklineMetric } from './kpi-sparkline-card'
export { KpiStatGrid, KpiStatCard, type KpiStatItem } from '@/components/kpi-stat-grid'
export { PanelCorners } from './panel-corners'
export { ProjectsEmptyState } from './projects-empty-state'
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])
}
+26
View File
@@ -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>
)
}
+92
View File
@@ -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 { format, isSameDay, parseISO } from 'date-fns'
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 { Calendar } from '@evobgp/ui/components/calendar'
import { Item } from '@evobgp/ui/components/item'
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 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({
jobs,
isLoading,
@@ -18,25 +72,12 @@ export function ScheduleAgendaPanel({
isLoading?: boolean
}) {
const [date, setDate] = useState<Date>(new Date())
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 [filter, setFilter] = useState<JobFilter>('all')
const markedDays = useMemo(() => {
const days = new Set<string>()
for (const job of jobs) {
const raw = job.created_at ?? job.started_at ?? job.finished_at
const raw = jobTimestamp(job)
if (!raw) continue
try {
days.add(format(parseISO(raw), 'yyyy-MM-dd'))
@@ -47,44 +88,85 @@ export function ScheduleAgendaPanel({
return days
}, [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 (
<PanelCard
title="Календарь задач"
description="Задачи refresh и apply по дням (schedule-1 pattern)"
className="h-full"
description="Задачи refresh и apply по дням"
contentClassName={cn(panelCardContentFlushClassName, 'p-0')}
>
<div className="grid gap-4 p-4 lg:grid-cols-[minmax(0,280px)_1fr]">
<Calendar
mode="single"
selected={date}
onSelect={(d) => d && setDate(d)}
locale={ru}
modifiers={{
hasJob: (d) => markedDays.has(format(d, 'yyyy-MM-dd')),
}}
modifiersClassNames={{ hasJob: 'font-bold underline' }}
/>
<ScrollArea className="h-64 lg:h-auto">
{isLoading ? (
<p className="text-muted-foreground text-sm">Загрузка</p>
) : dayJobs.length === 0 ? (
<p className="text-muted-foreground text-sm">
Нет задач за {format(date, 'd MMMM yyyy', { locale: ru })}
</p>
) : (
<ul className="space-y-2 pr-3">
{dayJobs.map((job) => (
<li key={job.job_id} className="rounded-lg border px-3 py-2 text-sm">
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{jobKindRu(job.kind)}</span>
<StatusBadge status={job.status} />
</div>
<p className="text-muted-foreground mt-1 text-xs">{job.job_id}</p>
</li>
))}
</ul>
)}
</ScrollArea>
<div className="flex flex-col lg:flex-row">
<div className="border-border shrink-0 border-b p-5 lg:w-[370px] lg:border-r lg:border-b-0">
<ScheduleCalendarView
selected={date}
onSelect={(d) => d && setDate(d)}
datesWithEvents={markedDays}
/>
</div>
<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">
<div className="min-w-0">
<h2 className="text-foreground text-sm font-semibold capitalize">{headingLabel}</h2>
<p className="text-muted-foreground text-xs">
{isLoading
? 'Загрузка…'
: dayJobs.length > 0
? `${dayJobs.length} ${dayJobs.length === 1 ? 'задача' : dayJobs.length < 5 ? 'задачи' : 'задач'}`
: 'Нет задач за выбранный день'}
</p>
</div>
<Select value={filter} onValueChange={(v) => v && setFilter(v as JobFilter)}>
<SelectTrigger size="sm" className="w-full sm:w-44">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FILTER_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</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>
</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}
isLoading={isLoading}
emptyMessage="Нет задач"
showPagination={items.length > 10}
searchValue={globalFilter}
onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск задач…"
+41 -90
View File
@@ -1,7 +1,7 @@
import type { ReactElement, ReactNode } from 'react'
import { Card, CardContent } from '@evobgp/ui/components/card'
import { cn } from '@evobgp/ui/lib/utils'
import { TruncatedText } from '@/components/truncated-text'
import { Badge } from '@/components/reui/badge'
import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
export interface SectionCardItem {
label: ReactNode
@@ -14,97 +14,48 @@ export interface SectionCardItem {
onClick?: () => void
}
const VARIANT_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
default: '',
warning: 'border-warning/50',
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',
const VARIANT_ICON_CLASS: Record<NonNullable<SectionCardItem['variant']>, string> = {
default: 'text-muted-foreground',
warning: 'text-warning',
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 }) {
return (
<div className={cn('grid gap-3', sectionGridClass(items.length), className)}>
{items.map((item, idx) => {
const clickable = Boolean(item.onClick)
const content = (
<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>
<KpiStatGrid
items={items.map(toKpiStatItem)}
className={className}
aria-label="Показатели"
/>
)
}
@@ -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,
}: {
title: string
description: string
description: ReactNode
badge?: { label: string; variant: ComponentProps<typeof Badge>['variant'] }
children: ReactNode
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'
}
+18 -21
View File
@@ -1,34 +1,37 @@
import { Skeleton } from '@evobgp/ui/components/skeleton'
import { KpiStatGrid } from '@/components/kpi-stat-grid'
import { panelCardInsetClassName } from '@/components/panel-card'
import {
chartPanelGridClassName,
dashboardMainSidebarClassName,
kpiGridClassName,
} from '@/lib/ui-surface'
import { SectionCards } from './section-cards'
export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
export function KpiStatGridSkeleton({ count = 4 }: { count?: number }) {
return (
<SectionCards
items={Array.from({ length: count }, (_, i) => ({
icon: <Skeleton className="size-4 rounded-sm" key={`icon-${i}`} />,
label: <Skeleton className="h-3 w-20" key={`label-${i}`} />,
value: <Skeleton className="h-5 w-16" key={`value-${i}`} />,
<KpiStatGrid
items={Array.from({ length: count }, (_, index) => ({
id: `kpi-skeleton-${index}`,
icon: <Skeleton className="size-4 rounded-sm" />,
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() {
return (
<div className="@container flex flex-col gap-4">
<div className={kpiGridClassName}>
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={`kpi-${i}`} className="h-36 w-full rounded-xl" />
))}
</div>
<KpiStatGridSkeleton count={6} />
<div className={chartPanelGridClassName}>
<Skeleton className="h-72 w-full rounded-xl" />
@@ -49,13 +52,7 @@ export function AnalyticsDashboardSkeleton() {
}
export function KpiSparklineSkeleton({ count = 4 }: { count?: number }) {
return (
<div className={kpiGridClassName}>
{Array.from({ length: count }).map((_, i) => (
<Skeleton key={i} className="h-36 w-full rounded-xl" />
))}
</div>
)
return <KpiStatGridSkeleton count={count} />
}
export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
+415
View File
@@ -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]
}
+18 -12
View File
@@ -1,23 +1,29 @@
/**
* EvoBGP UI surface contract (ReUI Pro).
*
* Primary surface: **Card** via `PanelCard` / `DataGridCard`.
* All route-level panels, KPI tiles, charts and data grids use shadcn Card —
* not ReUI Frame — for visual consistency across screens.
* Ops / list / dashboard / detail / settings: **Frame** only.
* KPI tiles: **stats-12** via `KpiStatGrid` / `reui-kit/KpiStatGrid`.
* Lists: Frame + DataGrid (`ResourcePage` / `DataGridSection`), not Card shell.
*
* ReUI Frame blocks from registry are adapted into Card composition when installed.
*
* @see https://reui.io/blocks — card category
* @see `.agents/skills/shadcn-react/SKILL.md`
* @see https://reui.io/preview/base/stats-12
* @see https://reui.io/preview/base/dashboard-1
* @see https://reui.io/preview/base/data-grid-filtering-2
* @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. */
export const kpiCardContentClassName = 'flex flex-col items-start gap-4 p-5'
/** Grid for ReUI stats-12 KPI rows (36 tiles). */
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 =
'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). */
export const chartPanelGridClassName = 'grid grid-cols-1 gap-4 @5xl:grid-cols-2'
+1 -1
View File
@@ -9,7 +9,7 @@ export const Route = createFileRoute('/_auth')({
const raw =
typeof window !== 'undefined' ? window.localStorage.getItem(TOKEN_STORAGE_KEY) : null
if (!raw || !normalizeApiToken(raw)) {
throw redirect({ to: '/settings', search: { reason: 'token-required' } })
throw redirect({ to: '/settings', search: { tab: 'connection', reason: 'token-required' } })
}
},
component: AuthLayout,
+21 -4
View File
@@ -8,6 +8,7 @@ import { PanelCard } from '@/components/panel-card'
import { AccessApiKeysCard } from '@/components/access/access-api-keys-card'
import { PageHeader } from '@/components/page-header'
import { Badge } from '@/components/reui/badge'
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
import { SectionCardsSkeleton } from '@/components/skeletons'
import { authSessionQueryOptions } from '@/queries/auth'
@@ -39,20 +40,32 @@ function AccessComponent() {
label: 'Всего ключей',
value: keys.length,
icon: <KeyRound className="size-4" />,
hint: 'в tenant',
badge: (
<Badge variant="primary-light" size="sm">
в tenant
</Badge>
),
},
{
label: 'Активных',
value: activeCount,
icon: <ShieldCheck className="size-4" />,
hint: 'не отозваны',
badge: (
<Badge variant="success-light" size="sm">
не отозваны
</Badge>
),
},
{
label: 'Отозванных',
value: revokedCount,
icon: <ShieldOff className="size-4" />,
hint: 'revoked',
variant: revokedCount > 0 ? 'warning' : 'default',
badge: (
<Badge variant={revokedCount > 0 ? 'warning-light' : 'outline'} size="sm">
revoked
</Badge>
),
},
],
[keys.length, activeCount, revokedCount],
@@ -96,7 +109,11 @@ function AccessComponent() {
) : (
<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>{' '}
(для dev-окружения <code className="text-xs">dev</code> при включённом demo-seed).
+13 -5
View File
@@ -1,5 +1,5 @@
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 { useState } from 'react'
@@ -8,7 +8,7 @@ import { Skeleton } from '@evobgp/ui/components/skeleton'
import { DashboardActivityTimeline } from '@/components/dashboard/dashboard-activity-timeline'
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 { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health'
import { DashboardOperationsBreakdown } from '@/components/dashboard/dashboard-operations-breakdown'
@@ -27,13 +27,21 @@ import {
overviewRevisionsQueryOptions,
overviewSpeakersQueryOptions,
} from '@/queries/overview'
import { settingsQueryOptions } from '@/queries/settings'
export const Route = createFileRoute('/_auth/dashboard')({
component: DashboardComponent,
})
function parseShowQuickActions(value: unknown): boolean {
if (value === false || value === 0 || value === 'false' || value === '0') return false
return true
}
function DashboardComponent() {
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
const settingsQ = useQuery(settingsQueryOptions())
const showQuickActions = parseShowQuickActions(settingsQ.data?.ui_show_quick_actions)
const results = useQueries({
queries: [
@@ -86,19 +94,19 @@ function DashboardComponent() {
}
/>
<DashboardQuickLinks />
{initialLoading ? (
<AnalyticsDashboardSkeleton />
) : (
<>
<DashboardKpiSparklineRow
<DashboardKpiGrid
modules={modules}
peers={peers}
speakers={speakers}
jobs={jobs}
/>
{showQuickActions ? <DashboardQuickLinks /> : null}
<div className={dashboardMainSidebarClassName}>
<div className="xl:col-span-8">
<DashboardModulesGrid modules={modules} isLoading={refreshing} />
+16 -3
View File
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
import { BookText, Globe, RefreshCw, Tags } from 'lucide-react'
import { Button } from '@evobgp/ui/components/button'
import { Badge } from '@/components/reui/badge'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { DataGridCard } from '@/components/data-grid-shell'
import { DirectoriesCommunitiesGrid } from '@/components/directories/directories-communities-grid'
@@ -30,19 +31,31 @@ function DirectoriesComponent() {
label: 'Сообщества BGP',
value: communities.length,
icon: <Tags className="size-4" />,
hint: 'теги префиксов в AS- и CDN-модулях',
badge: (
<Badge variant="primary-light" size="sm">
теги префиксов
</Badge>
),
},
{
label: 'DoH профили',
value: dohProfiles.length,
icon: <Globe className="size-4" />,
hint: 'резолвинг доменных модулей',
badge: (
<Badge variant="info-light" size="sm">
резолвинг доменов
</Badge>
),
},
{
label: 'Справочники',
value: 'Общие',
icon: <BookText className="size-4" />,
hint: 'используются всеми модулями tenant',
badge: (
<Badge variant="outline" size="sm">
все модули tenant
</Badge>
),
},
]
+34 -43
View File
@@ -5,10 +5,10 @@ import { toast } from 'sonner'
import { useState } from 'react'
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 { 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 { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state'
@@ -18,7 +18,6 @@ import { SectionCardsSkeleton } from '@/components/skeletons'
import { operationsJobsQueryOptions } from '@/queries/operations'
import { modulesListQueryOptions } from '@/queries/modules'
import { apiMutate } from '@/lib/api-client'
import type { JobRow } from '@/types/api'
export const Route = createFileRoute('/_auth/schedule')({
component: ScheduleComponent,
@@ -39,14 +38,36 @@ function ScheduleComponent() {
).length
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: 'С ошибкой',
value: failed,
icon: <AlertTriangle className="size-4" />,
hint: failed > 0 ? 'требуют внимания' : 'без ошибок',
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>
</DataGridCard>
<DataGridCard title="Задачи" description="Последние задачи из API">
<JobsTabs jobs={jobs} loading={jobsQ.isLoading} />
</DataGridCard>
<ScheduleJobsCard
jobs={jobs}
isLoading={jobsQ.isLoading}
isError={jobsQ.isError}
error={jobsQ.error}
onRetry={() => jobsQ.refetch()}
/>
</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>
)
}
+32 -124
View File
@@ -1,143 +1,51 @@
import { createFileRoute, useNavigate, useRouterState } from '@tanstack/react-router'
import { useQuery, useQueryClient } from '@tanstack/react-query'
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 { createFileRoute } from '@tanstack/react-router'
import { z } from 'zod'
import { PageHeader } from '@/components/page-header'
import { LoadingButton } from '@/components/loading-button'
import { SelectField } from '@/components/select-field'
import { DEV_API_TOKEN, normalizeApiToken, setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
import { authKeys, authSessionQueryOptions } from '@/queries/auth'
import { toast } from 'sonner'
import { Save } from 'lucide-react'
import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react'
import { SettingsPageShell } from '@/components/settings/settings-page-shell'
import {
type SettingsTab,
} from '@/components/settings/settings-tabs-data'
const settingsSearchSchema = z.object({
tab: z
.enum(['connection', 'session', 'appearance', 'sections'])
.catch('connection'),
reason: z.enum(['token-required']).optional(),
})
export const Route = createFileRoute('/_auth/settings')({
component: SettingsComponent,
validateSearch: (search) => settingsSearchSchema.parse(search),
})
const THEME_SELECT_ITEMS = [
{ value: 'light', label: 'Светлая' },
{ value: 'dark', label: 'Тёмная' },
{ value: 'system', label: 'Как в системе' },
] as const
function SettingsComponent() {
const navigate = useNavigate()
const tokenRequired = useRouterState({
select: (s) => new URLSearchParams(s.location.search).get('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('')
const navigate = Route.useNavigate()
const { tab, reason } = Route.useSearch()
const tokenRequired = reason === 'token-required'
useEffect(() => {
const t = window.localStorage.getItem(TOKEN_STORAGE_KEY) ?? ''
setTokenValue(t)
}, [])
async function applyToken(raw: string) {
const normalized = normalizeApiToken(raw)
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)
function handleTabChange(nextTab: SettingsTab) {
void navigate({
search: (prev) => ({
...prev,
tab: nextTab,
}),
replace: true,
})
}
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
title="Настройки"
description="Параметры интерфейса и подключения браузера к API."
description="Параметры интерфейса и подключения браузера к API. Параметры tenant — во вкладке «Разделы»."
/>
{tokenRequired ? (
<Alert>
<AlertDescription>
Для доступа к разделу нужен API-токен. Сохраните токен ниже или нажмите «Использовать dev».
</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>
<SettingsPageShell
activeTab={tab}
onTabChange={handleTabChange}
tokenRequired={tokenRequired}
/>
</div>
)
}
File diff suppressed because one or more lines are too long
+4
View File
@@ -1455,6 +1455,10 @@ components:
description: UTC cron для автоочистки (по умолчанию `0 */6 * * *`).
runtime_logs_auto_mode:
$ref: "#/components/schemas/RuntimeLogCleanupMode"
ui_show_quick_actions:
type: boolean
description: Показывать блок «Быстрые действия» на дашборде (UI preference).
default: true
additionalProperties: true
RevisionDiff:
+76
View File
@@ -0,0 +1,76 @@
# 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 |
## 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`).
## System monitor
`SystemMonitorPopover` in app-shell header next to `ModeToggle`. 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
- Main: `gap-4 md:gap-6`, `px-4 md:px-6`
- 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
+2
View File
@@ -1,3 +1,5 @@
"use client"
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
+2
View File
@@ -1,3 +1,5 @@
"use client"
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
@@ -1,5 +1,3 @@
"use client"
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
-2
View File
@@ -1,5 +1,3 @@
"use client"
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
+2
View File
@@ -1,3 +1,5 @@
"use client"
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@evobgp/ui/lib/utils"
+2
View File
@@ -1,3 +1,5 @@
"use client"
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "@evobgp/ui/lib/utils"
+6 -6
View File
@@ -13,9 +13,9 @@ function Tabs({
return (
<TabsPrimitive.Root
data-slot="tabs"
data-orientation={orientation}
orientation={orientation}
className={cn(
"group/tabs flex gap-2 data-horizontal:flex-col",
"group/tabs flex gap-2 data-[orientation=horizontal]:flex-col",
className
)}
{...props}
@@ -24,12 +24,12 @@ function Tabs({
}
const tabsListVariants = cva(
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
"group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-[orientation=horizontal]/tabs:h-9 group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col data-[variant=line]:rounded-none",
{
variants: {
variant: {
default: "bg-muted",
line: "gap-1 bg-transparent",
line: "gap-1 border-b border-border bg-transparent",
},
},
defaultVariants: {
@@ -58,10 +58,10 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
<TabsPrimitive.Tab
data-slot="tabs-trigger"
className={cn(
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-[orientation=vertical]/tabs:w-full group-data-[orientation=vertical]/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 aria-disabled:pointer-events-none aria-disabled:opacity-50 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
"group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
"data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
"after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-[orientation=horizontal]/tabs:after:inset-x-0 group-data-[orientation=horizontal]/tabs:after:bottom-[-5px] group-data-[orientation=horizontal]/tabs:after:h-0.5 group-data-[orientation=vertical]/tabs:after:inset-y-0 group-data-[orientation=vertical]/tabs:after:-right-1 group-data-[orientation=vertical]/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
className
)}
{...props}
+1
View File
@@ -14,6 +14,7 @@ Thumbs.db
# Env
.env
.env.local
.env.*
!.env.example
!.env.test