diff --git a/.cursor/debug-ce85d7.log b/.cursor/debug-ce85d7.log
new file mode 100644
index 0000000..95bd0a2
--- /dev/null
+++ b/.cursor/debug-ce85d7.log
@@ -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}
diff --git a/apps/web/src/components/analytics/analytics-kpi-row.tsx b/apps/web/src/components/analytics/analytics-kpi-row.tsx
index 0a0d936..4a44722 100644
--- a/apps/web/src/components/analytics/analytics-kpi-row.tsx
+++ b/apps/web/src/components/analytics/analytics-kpi-row.tsx
@@ -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
- if (direction === 'down') return
- return
+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 ? (
+
+ {item.delta.label}
+
+ ) : undefined,
+ }
+}
+
+/** Compact KPI row inside analytics panels (stats-12 embedded tiles). */
+export function AnalyticsKpiRow({
+ items,
+ className,
+}: {
+ items: AnalyticsKpiItem[]
+ className?: string
+}) {
return (
-
- {items.map((item) => (
-
-
{item.label}
-
{item.value}
- {item.delta ? (
-
-
- {item.delta.label}
-
- ) : null}
-
- ))}
-
+
)
}
diff --git a/apps/web/src/components/badge-tabs.tsx b/apps/web/src/components/badge-tabs.tsx
index 208ef0e..4a6f673 100644
--- a/apps/web/src/components/badge-tabs.tsx
+++ b/apps/web/src/components/badge-tabs.tsx
@@ -40,14 +40,12 @@ export function BadgeTabs({
value={value}
defaultValue={defaultValue}
onValueChange={onValueChange}
+ orientation="horizontal"
className={cn('w-full', className)}
>
{items.map((item) => (
diff --git a/apps/web/src/components/blocks/settings-7/components/account-settings.tsx b/apps/web/src/components/blocks/settings-7/components/account-settings.tsx
new file mode 100644
index 0000000..83c2039
--- /dev/null
+++ b/apps/web/src/components/blocks/settings-7/components/account-settings.tsx
@@ -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 = {
+ profile: ProfileTab,
+ security: SecurityTab,
+ notifications: NotificationsTab,
+ billing: BillingTab,
+}
+
+// ── Settings Navigation ──
+
+function SettingsNavigation({
+ isMobile,
+ activeValue,
+}: {
+ isMobile: boolean
+ activeValue: string
+}) {
+ return (
+
+ {isMobile ? (
+
+
+ {SETTINGS_TAB_ITEMS.map((tab) => (
+
+ {tab.icon}
+ {tab.label}
+
+ ))}
+
+
+ ) : (
+
+ {SETTINGS_TAB_ITEMS.map((tab) => (
+
+ {tab.icon}
+ {tab.label}
+
+ ))}
+
+ )}
+
+ )
+}
+
+export function AccountSettings() {
+ const isMobile = useIsMobile()
+ const [activeTab, setActiveTab] = useState("profile")
+
+ return (
+
+ {/* Header */}
+
+
+ Account Settings
+
+
+ Update your profile, access, notifications, and billing preferences.
+
+
+
+ {/* Tabs */}
+
+
+
+
+ {SETTINGS_TAB_ITEMS.map((tab) => {
+ const TabComponent = TAB_COMPONENTS[tab.value]
+
+ return (
+
+
+
+ )
+ })}
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/settings-7/components/billing-tab.tsx b/apps/web/src/components/blocks/settings-7/components/billing-tab.tsx
new file mode 100644
index 0000000..604dfa8
--- /dev/null
+++ b/apps/web/src/components/blocks/settings-7/components/billing-tab.tsx
@@ -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 (
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/settings-7/components/data.tsx b/apps/web/src/components/blocks/settings-7/components/data.tsx
new file mode 100644
index 0000000..f2ef237
--- /dev/null
+++ b/apps/web/src/components/blocks/settings-7/components/data.tsx
@@ -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: (
+
+ ),
+ },
+ {
+ value: "security",
+ label: "Security",
+ icon: (
+
+ ),
+ },
+ {
+ value: "notifications",
+ label: "Notifications",
+ icon: (
+
+ ),
+ },
+ {
+ value: "billing",
+ label: "Billing",
+ icon: (
+
+ ),
+ },
+]
+
+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: (
+
+ ),
+ },
+ {
+ id: "sess-2",
+ device: "iPhone",
+ browser: "Safari",
+ location: "San Francisco, CA",
+ lastActive: "2 hours ago",
+ current: false,
+ icon: (
+
+ ),
+ },
+ {
+ id: "sess-3",
+ device: "Windows",
+ browser: "Firefox",
+ location: "New York, NY",
+ lastActive: "3 days ago",
+ current: false,
+ icon: (
+
+ ),
+ },
+]
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/settings-7/components/notifications-tab.tsx b/apps/web/src/components/blocks/settings-7/components/notifications-tab.tsx
new file mode 100644
index 0000000..de702ad
--- /dev/null
+++ b/apps/web/src/components/blocks/settings-7/components/notifications-tab.tsx
@@ -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 (
+
+ {/* Card */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ >
+ }
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/settings-7/components/profile-avatar-upload.tsx b/apps/web/src/components/blocks/settings-7/components/profile-avatar-upload.tsx
new file mode 100644
index 0000000..03b95e8
--- /dev/null
+++ b/apps/web/src/components/blocks/settings-7/components/profile-avatar-upload.tsx
@@ -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 (
+
+ {/* Actions */}
+
+
+
+
+
+
+
+
+ {currentFile ? (
+
+ ) : null}
+
+
+
+
+
+
+
+ {hasPhoto ? (
+
+ ) : null}
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/settings-7/components/profile-tab.tsx b/apps/web/src/components/blocks/settings-7/components/profile-tab.tsx
new file mode 100644
index 0000000..68298c9
--- /dev/null
+++ b/apps/web/src/components/blocks/settings-7/components/profile-tab.tsx
@@ -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 (
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/settings-7/components/security-tab.tsx b/apps/web/src/components/blocks/settings-7/components/security-tab.tsx
new file mode 100644
index 0000000..611c917
--- /dev/null
+++ b/apps/web/src/components/blocks/settings-7/components/security-tab.tsx
@@ -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 (
+
+ {/* Card */}
+
Update password}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Recommended
+
+ }
+ >
+
+
+
+
+
+
+ +1
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {SESSIONS.map((session, index) => (
+
+ {index > 0 ? : null}
+ -
+
+
-
+ {session.icon}
+
+
+
+
+
+ {session.browser} on {session.device}
+ {session.current ? (
+
+ Current
+
+ ) : null}
+
+
+ {session.location} · {session.lastActive}
+
+
+
+
+ {session.current ? (
+
+ ) : (
+
+ )}
+
+
+
+ ))}
+
+
+
+
+
+
+
+
+ This action cannot be undone.
+
+
+
+
+
+
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/settings-7/components/setting-row.tsx b/apps/web/src/components/blocks/settings-7/components/setting-row.tsx
new file mode 100644
index 0000000..274402a
--- /dev/null
+++ b/apps/web/src/components/blocks/settings-7/components/setting-row.tsx
@@ -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 (
+ <>
+
+
+
+ {labelFor ? (
+ {title}
+ ) : (
+ {title}
+ )}
+ {titleAddon}
+
+
+ {description ? (
+
{description}
+ ) : null}
+
+
+
+
+ {children}
+
+
+
+
+ {!last ? : null}
+ >
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/settings-7/components/settings-card.tsx b/apps/web/src/components/blocks/settings-7/components/settings-card.tsx
new file mode 100644
index 0000000..9829937
--- /dev/null
+++ b/apps/web/src/components/blocks/settings-7/components/settings-card.tsx
@@ -0,0 +1,59 @@
+"use client"
+
+import { type ReactNode } from "react"
+
+import { cn } from "@evobgp/ui/lib/utils"
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from "@evobgp/ui/components/card"
+
+interface SettingsCardProps {
+ title: string
+ description?: string
+ children: ReactNode
+ footer?: ReactNode
+ className?: string
+ contentClassName?: string
+ footerClassName?: string
+}
+
+export function SettingsCard({
+ title,
+ description,
+ children,
+ footer,
+ className,
+ contentClassName,
+ footerClassName,
+}: SettingsCardProps) {
+ return (
+
+ {/* Header */}
+
+ {title}
+ {description ? {description} : null}
+
+
+ {/* Content */}
+
+ {children}
+
+
+ {footer ? (
+
+ {footer}
+
+ ) : null}
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/settings-7/components/settings-field-group.tsx b/apps/web/src/components/blocks/settings-7/components/settings-field-group.tsx
new file mode 100644
index 0000000..0ee4249
--- /dev/null
+++ b/apps/web/src/components/blocks/settings-7/components/settings-field-group.tsx
@@ -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 (
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/settings-7/components/utils.ts b/apps/web/src/components/blocks/settings-7/components/utils.ts
new file mode 100644
index 0000000..51aaf03
--- /dev/null
+++ b/apps/web/src/components/blocks/settings-7/components/utils.ts
@@ -0,0 +1,22 @@
+"use client"
+
+import { type Dispatch, type SetStateAction } from "react"
+
+import type { SelectOption } from "./data"
+
+export function getOptionLabel(
+ options: T[],
+ value: string
+) {
+ return options.find((option) => option.value === value)?.label ?? value
+}
+
+export function createSelectValueHandler(
+ setValue: Dispatch>
+) {
+ return (value: string | null) => {
+ if (value !== null) {
+ setValue(value)
+ }
+ }
+}
\ No newline at end of file
diff --git a/apps/web/src/components/blocks/settings-7/page.tsx b/apps/web/src/components/blocks/settings-7/page.tsx
new file mode 100644
index 0000000..c7aa75b
--- /dev/null
+++ b/apps/web/src/components/blocks/settings-7/page.tsx
@@ -0,0 +1,9 @@
+import { AccountSettings } from "./components/account-settings"
+
+export function Page() {
+ return (
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/components/dashboard/dashboard-kpi-grid.tsx b/apps/web/src/components/dashboard/dashboard-kpi-grid.tsx
index 3a69c72..e6964b0 100644
--- a/apps/web/src/components/dashboard/dashboard-kpi-grid.tsx
+++ b/apps/web/src/components/dashboard/dashboard-kpi-grid.tsx
@@ -8,22 +8,13 @@ import {
} from 'lucide-react'
import type { ReactNode } from 'react'
+import { KpiStatGrid, type KpiStatItem } from '@/components/kpi-stat-grid'
import { Badge } from '@/components/reui/badge'
-import { dashboardKpiGridClassName, kpiCardContentClassName } from '@/lib/ui-surface'
-import { Card, CardContent } from '@evobgp/ui/components/card'
-import { cn } from '@evobgp/ui/lib/utils'
-import { Item, ItemMedia } from '@evobgp/ui/components/item'
import { aggregateNetworkMetrics, runningJobCount } from '@/queries/overview'
import type { JobRow, ModuleRow, PeerRow, SpeakerRow } from '@/types/api'
-type KpiCard = {
- icon: ReactNode
- iconClass: string
- value: string
- label: string
- badge: ReactNode
-}
+type KpiCard = KpiStatItem & { icon: ReactNode }
function buildKpis({
modules,
@@ -52,22 +43,24 @@ function buildKpis({
return [
{
+ id: 'modules',
icon: ,
- iconClass: 'text-primary',
+ iconClassName: 'text-primary',
value: loading ? '—' : `${enabledModules}/${modules.length || 0}`,
label: 'Модули активны',
- badge: (
+ footer: (
{loading ? '…' : `${modules.length} всего`}
),
},
{
+ id: 'bgp',
icon: ,
- iconClass: 'text-info',
+ iconClassName: 'text-info',
value: loading || bgpPct === null ? '—' : `${bgpPct}%`,
label: 'BGP готовность',
- badge: (
+ footer: (
= 90
@@ -85,22 +78,24 @@ function buildKpis({
),
},
{
+ id: 'peers',
icon: ,
- iconClass: 'text-success',
+ iconClassName: 'text-success',
value: loading ? '—' : `${network.peersEstablished}/${peersEnabled}`,
label: 'Пиры Established',
- badge: (
+ footer: (
{loading ? '…' : `${network.peersTotal} в каталоге`}
),
},
{
+ id: 'speakers',
icon: ,
- iconClass: 'text-warning',
+ iconClassName: 'text-warning',
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
label: 'Спикеры online',
- badge: (
+ footer: (
,
- iconClass: 'text-focus',
+ iconClassName: 'text-focus',
value: loading ? '—' : String(running),
label: 'Активные задачи',
- badge: (
+ footer: (
0 ? 'info-light' : 'outline'} size="sm">
{loading ? '…' : `${jobs.length} в выборке`}
),
},
{
+ id: 'risks',
icon: ,
- iconClass: 'text-destructive',
+ iconClassName: 'text-destructive',
value: loading ? '—' : String(riskCount),
label: 'Риски',
- badge: (
+ footer: (
0 ? 'destructive-light' : 'success-light'} size="sm">
{loading
? '…'
@@ -151,33 +148,10 @@ export function DashboardKpiGrid({
jobs: JobRow[]
loading?: boolean
}) {
- const cards = buildKpis({ modules, peers, speakers, jobs, loading })
-
return (
-
- {cards.map((card) => (
-
-
- -
-
- {card.icon}
-
-
-
-
- {card.value}
-
-
{card.label}
-
- {card.badge}
-
-
- ))}
-
+
)
}
diff --git a/apps/web/src/components/examples/c-tabs-2.tsx b/apps/web/src/components/examples/c-tabs-2.tsx
new file mode 100644
index 0000000..48cac88
--- /dev/null
+++ b/apps/web/src/components/examples/c-tabs-2.tsx
@@ -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 (
+
+ )
+}
\ No newline at end of file
diff --git a/apps/web/src/components/kpi-stat-grid.tsx b/apps/web/src/components/kpi-stat-grid.tsx
new file mode 100644
index 0000000..24dbc2a
--- /dev/null
+++ b/apps/web/src/components/kpi-stat-grid.tsx
@@ -0,0 +1,126 @@
+import type { KeyboardEvent, ReactNode } from 'react'
+
+import { Frame, FramePanel } from '@/components/reui/frame'
+import { cn } from '@evobgp/ui/lib/utils'
+import { Item, ItemMedia } from '@evobgp/ui/components/item'
+
+export type KpiStatItem = {
+ id?: string
+ icon?: ReactNode
+ iconClassName?: string
+ value: ReactNode
+ label: ReactNode
+ footer?: ReactNode
+ active?: boolean
+ onClick?: () => void
+}
+
+const DEFAULT_ICON_CLASS =
+ 'text-muted-foreground [&_svg]:text-current'
+
+function kpiStatGridClassName(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(onClick: () => void, event: KeyboardEvent) {
+ if (event.key === 'Enter' || event.key === ' ') {
+ event.preventDefault()
+ onClick()
+ }
+}
+
+/** Single KPI tile (ReUI stats-12 / dashboard PRO pattern). */
+export function KpiStatCard({
+ item,
+ embedded = false,
+ className,
+}: {
+ item: KpiStatItem
+ embedded?: boolean
+ className?: string
+}) {
+ const clickable = Boolean(item.onClick)
+
+ const panel = (
+ handleCardKeyDown(item.onClick!, e) : undefined}
+ >
+ {item.icon ? (
+ -
+
+ {item.icon}
+
+
+ ) : null}
+
+
+
{item.value}
+
{item.label}
+
+
+ {item.footer ? {item.footer}
: null}
+
+ )
+
+ if (embedded) {
+ return (
+
+ {panel}
+
+ )
+ }
+
+ return {panel}
+}
+
+export function KpiStatGrid({
+ items,
+ className,
+ embedded = false,
+ 'aria-label': ariaLabel,
+}: {
+ items: KpiStatItem[]
+ className?: string
+ embedded?: boolean
+ 'aria-label'?: string
+}) {
+ return (
+
+
+ {items.map((item, index) => (
+
+ ))}
+
+
+ )
+}
+
+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}`
+}
diff --git a/apps/web/src/components/layout/app-shell.tsx b/apps/web/src/components/layout/app-shell.tsx
index 4eadfeb..6ed4522 100644
--- a/apps/web/src/components/layout/app-shell.tsx
+++ b/apps/web/src/components/layout/app-shell.tsx
@@ -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' } },
],
},
]
diff --git a/apps/web/src/components/modules/module-kpi-cards.tsx b/apps/web/src/components/modules/module-kpi-cards.tsx
index 2c1a756..7a42662 100644
--- a/apps/web/src/components/modules/module-kpi-cards.tsx
+++ b/apps/web/src/components/modules/module-kpi-cards.tsx
@@ -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
+ return
}
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: ,
+ 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: (
+
+ {mod.enabled !== false ? 'активен' : 'выключен'} · {mod.type}
+
+ ),
},
{
id: 'interval',
- title: 'Интервал',
- label: 'Обновление',
+ icon: ,
+ 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: (
+
+ {formatDateTime(mod.last_refreshed_at) || 'никогда'}
+
+ ),
},
{
id: 'prefixes',
- title: 'Префиксы',
- label: mod.type === 'AS_PREFIXES' ? 'AS entries' : 'Записи',
+ icon: ,
+ 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: (
+
+ {asEntries.length} AS · в модуле
+
+ ),
},
{
id: 'policy',
- title: 'DoH / BGP',
- label: communityLabel(mod.default_community_id, communities),
+ icon: ,
+ 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: (
+
+ {dohPolicyRu(mod.doh_resolver_policy)}
+ {dohIds.length > 0
+ ? ` · ${dohIds.map((id) => dohProfileLabel(id, dohProfiles)).join(', ')}`
+ : ' · без DoH'}
+
+ ),
},
]
- return (
-
- {metrics.map((metric) => (
-
- ))}
-
- )
+ return
}
diff --git a/apps/web/src/components/network/network-peers-card.tsx b/apps/web/src/components/network/network-peers-card.tsx
index b0779c2..efe130f 100644
--- a/apps/web/src/components/network/network-peers-card.tsx
+++ b/apps/web/src/components/network/network-peers-card.tsx
@@ -65,9 +65,9 @@ export function NetworkPeersCard({
}
>
-
-
setTab(v as PeerTab)}>
-
+
+
setTab(v as PeerTab)} className="w-full">
+
Все ({counts.all})
Established ({counts.established})
Ожидание ({counts.pending})
diff --git a/apps/web/src/components/operations/operations-jobs-card.tsx b/apps/web/src/components/operations/operations-jobs-card.tsx
index 319a05d..5782d3a 100644
--- a/apps/web/src/components/operations/operations-jobs-card.tsx
+++ b/apps/web/src/components/operations/operations-jobs-card.tsx
@@ -52,9 +52,9 @@ export function OperationsJobsCard({
return (
-
-
setTab(v as JobTab)}>
-
+
+
setTab(v as JobTab)} className="w-full">
+
Все ({counts.all})
Активные ({counts.active})
Успешные ({counts.succeeded})
diff --git a/apps/web/src/components/patterns/index.ts b/apps/web/src/components/patterns/index.ts
index 2b750b4..a48b262 100644
--- a/apps/web/src/components/patterns/index.ts
+++ b/apps/web/src/components/patterns/index.ts
@@ -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'
diff --git a/apps/web/src/components/reui/alert.tsx b/apps/web/src/components/reui/alert.tsx
new file mode 100644
index 0000000..2ec5b50
--- /dev/null
+++ b/apps/web/src/components/reui/alert.tsx
@@ -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) {
+ return (
+
+ )
+}
+
+function AlertTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertDescription({
+ className,
+ ...props
+}: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function AlertAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Alert, AlertTitle, AlertDescription, AlertAction }
\ No newline at end of file
diff --git a/apps/web/src/components/schedule/schedule-jobs-card.tsx b/apps/web/src/components/schedule/schedule-jobs-card.tsx
index ea6f664..6607cf2 100644
--- a/apps/web/src/components/schedule/schedule-jobs-card.tsx
+++ b/apps/web/src/components/schedule/schedule-jobs-card.tsx
@@ -46,9 +46,9 @@ export function ScheduleJobsCard({
return (
-
-
setTab(v as JobTab)}>
-
+
+
setTab(v as JobTab)} className="w-full">
+
Все ({counts.all})
Обновление ({counts.refresh})
С ошибкой ({counts.failed})
diff --git a/apps/web/src/components/section-cards.tsx b/apps/web/src/components/section-cards.tsx
index f6111da..f5e9358 100644
--- a/apps/web/src/components/section-cards.tsx
+++ b/apps/web/src/components/section-cards.tsx
@@ -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,46 @@ export interface SectionCardItem {
onClick?: () => void
}
-const VARIANT_CLASS: Record, 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, string> = {
- default: '',
- warning: 'text-warning-foreground',
+const VARIANT_ICON_CLASS: Record, string> = {
+ default: 'text-muted-foreground',
+ warning: 'text-warning',
destructive: 'text-destructive',
}
+function hintToFooter(hint: ReactNode) {
+ if (typeof hint === 'string') {
+ return (
+
+ {hint}
+
+ )
+ }
+ return hint
+}
+
+function toKpiStatItem(item: SectionCardItem, index: number): KpiStatItem {
+ 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[item.variant ?? 'default'],
+ value: item.value,
+ label: item.label,
+ 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 (
-
- {items.map((item, idx) => {
- const clickable = Boolean(item.onClick)
- const content = (
-
- {item.icon ? (
-
- {item.icon}
-
- ) : null}
-
-
- {typeof item.label === 'string' ? (
- {item.label}
- ) : (
- {item.label}
- )}
- {item.badge ? {item.badge} : null}
-
-
-
- {item.value}
-
- {item.hint ? (
- typeof item.hint === 'string' ? (
- · {item.hint}
- ) : (
- · {item.hint}
- )
- ) : null}
-
-
-
- )
- return (
-
{
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault()
- item.onClick?.()
- }
- }
- : undefined
- }
- >
- {content}
-
- )
- })}
-
+
)
}
diff --git a/apps/web/src/components/settings/appearance-settings-tab.tsx b/apps/web/src/components/settings/appearance-settings-tab.tsx
new file mode 100644
index 0000000..257bbc2
--- /dev/null
+++ b/apps/web/src/components/settings/appearance-settings-tab.tsx
@@ -0,0 +1,68 @@
+import { Moon, Sun, SunMoon } from 'lucide-react'
+import { useTheme } from 'next-themes'
+
+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'
+
+const THEME_OPTIONS = [
+ { value: 'light', label: 'Светлая', icon: Sun },
+ { value: 'dark', label: 'Тёмная', icon: Moon },
+ { value: 'system', label: 'Система', icon: SunMoon },
+] as const
+
+export function AppearanceSettingsTab() {
+ const { theme, setTheme } = useTheme()
+
+ return (
+
+
+
+
+ мгновенно
+
+ }
+ last
+ >
+ {
+ 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 (
+
+
+ {option.label}
+
+ )
+ })}
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/settings/connection-settings-tab.tsx b/apps/web/src/components/settings/connection-settings-tab.tsx
new file mode 100644
index 0000000..0508fe9
--- /dev/null
+++ b/apps/web/src/components/settings/connection-settings-tab.tsx
@@ -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 (
+
+ {tokenRequired ? (
+
+
+ Для доступа к разделу нужен API-токен. Сохраните токен ниже или нажмите «Использовать
+ dev».
+
+
+ ) : null}
+
+
+
+
+
+ Сохранить токен
+
+
+ }
+ >
+
+
+ Ключ для заголовка Authorization. Управление
+ ключами tenant — в разделе{' '}
+
+ Права доступа
+
+ .
+ >
+ }
+ titleAddon={
+
+ localStorage
+
+ }
+ labelFor="settings-token"
+ last
+ >
+ setTokenValue(e.target.value)}
+ placeholder="dev или API-ключ"
+ />
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/settings/sections-settings-tab.tsx b/apps/web/src/components/settings/sections-settings-tab.tsx
new file mode 100644
index 0000000..e1cb265
--- /dev/null
+++ b/apps/web/src/components/settings/sections-settings-tab.tsx
@@ -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: ,
+ badge: { label: 'operator', variant: 'warning-light' as const },
+ },
+ {
+ id: 'access',
+ to: '/access' as const,
+ title: 'Права доступа',
+ description: 'API-ключи tenant, роли и управление доступом.',
+ icon: ,
+ badge: { label: 'operator', variant: 'warning-light' as const },
+ },
+ {
+ id: 'monitoring',
+ to: '/monitoring' as const,
+ title: 'Мониторинг',
+ description: 'Метрики, состояние jobs и observability control plane.',
+ icon: ,
+ badge: { label: 'viewer+', variant: 'info-light' as const },
+ },
+] as const
+
+export function SectionsSettingsTab() {
+ return (
+
+
+
+ {ADMIN_SECTIONS.map((section, index) => (
+
+ {index > 0 ? : null}
+ -
+
+
-
+ {section.icon}
+
+
+
+
+
+ {section.title}
+
+ {section.badge.label}
+
+
+ {section.description}
+
+
+
+ }>
+ Открыть
+
+
+
+
+ ))}
+
+
+
+ )
+}
diff --git a/apps/web/src/components/settings/session-settings-tab.tsx b/apps/web/src/components/settings/session-settings-tab.tsx
new file mode 100644
index 0000000..a6c8dd7
--- /dev/null
+++ b/apps/web/src/components/settings/session-settings-tab.tsx
@@ -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 = {
+ 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 (
+
+
+ }
+ onRetry={() => sessionQ.refetch()}
+ >
+ {(session) => (
+
+ -
+
+
-
+
+
+
+
+
+
+ Этот браузер
+
+ Активна
+
+
+
+ Tenant:{' '}
+ {session.tenant_id}
+
+
+
+
+ )}
+
+
+
+
+
+
+ {ROLE_LABELS[sessionQ.data.role] ?? sessionQ.data.role}
+
+ ) : (
+
+ неизвестно
+
+ )
+ }
+ last
+ >
+ {sessionQ.data ? (
+
+ Ключ с ролью {sessionQ.data.role} в tenant{' '}
+ {sessionQ.data.tenant_id}.
+
+ ) : (
+
+ Сохраните токен и дождитесь проверки сессии.
+
+ )}
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/settings/settings-page-shell.tsx b/apps/web/src/components/settings/settings-page-shell.tsx
new file mode 100644
index 0000000..c42e231
--- /dev/null
+++ b/apps/web/src/components/settings/settings-page-shell.tsx
@@ -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 (
+ onTabChange(value as SettingsTab)}
+ items={SETTINGS_TAB_ITEMS.map((tab) => ({
+ value: tab.value,
+ label: tab.label,
+ icon: tab.icon,
+ }))}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/web/src/components/settings/settings-tabs-data.tsx b/apps/web/src/components/settings/settings-tabs-data.tsx
new file mode 100644
index 0000000..badc207
--- /dev/null
+++ b/apps/web/src/components/settings/settings-tabs-data.tsx
@@ -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: ,
+ },
+ {
+ value: 'session',
+ label: 'Сессия',
+ icon: ,
+ },
+ {
+ value: 'appearance',
+ label: 'Оформление',
+ icon: ,
+ },
+ {
+ value: 'sections',
+ label: 'Разделы',
+ icon: ,
+ },
+]
+
+export function parseSettingsTab(value: unknown): SettingsTab {
+ if (
+ value === 'session' ||
+ value === 'appearance' ||
+ value === 'sections'
+ ) {
+ return value
+ }
+ return 'connection'
+}
diff --git a/apps/web/src/components/skeletons.tsx b/apps/web/src/components/skeletons.tsx
index 169fa71..420d7ec 100644
--- a/apps/web/src/components/skeletons.tsx
+++ b/apps/web/src/components/skeletons.tsx
@@ -1,35 +1,37 @@
import { Skeleton } from '@evobgp/ui/components/skeleton'
+import { KpiStatGrid } from '@/components/kpi-stat-grid'
import { panelCardInsetClassName } from '@/components/panel-card'
import {
chartPanelGridClassName,
- dashboardKpiGridClassName,
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 (
- ({
- icon: ,
- label: ,
- value: ,
+ ({
+ id: `kpi-skeleton-${index}`,
+ icon: ,
+ value: ,
+ label: ,
+ footer: ,
}))}
+ 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
+}
+
+/** Matches dashboard layout: KPI row, charts, main+sidebar, bottom grids. */
export function AnalyticsDashboardSkeleton() {
return (
-
- {Array.from({ length: 6 }).map((_, i) => (
-
- ))}
-
+
@@ -50,13 +52,7 @@ export function AnalyticsDashboardSkeleton() {
}
export function KpiSparklineSkeleton({ count = 4 }: { count?: number }) {
- return (
-
- {Array.from({ length: count }).map((_, i) => (
-
- ))}
-
- )
+ return
}
export function TableSkeleton({ rows = 6, cols = 4 }: { rows?: number; cols?: number }) {
diff --git a/apps/web/src/hooks/use-file-upload.ts b/apps/web/src/hooks/use-file-upload.ts
new file mode 100644
index 0000000..dc31bca
--- /dev/null
+++ b/apps/web/src/hooks/use-file-upload.ts
@@ -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
) => void
+ handleDragLeave: (e: DragEvent) => void
+ handleDragOver: (e: DragEvent) => void
+ handleDrop: (e: DragEvent) => void
+ handleFileChange: (e: ChangeEvent) => void
+ openFileDialog: () => void
+ getInputProps: (
+ props?: InputHTMLAttributes
+ ) => InputHTMLAttributes & {
+ ref: React.Ref
+ }
+}
+
+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({
+ files: initialFiles.map((file) => ({
+ file,
+ id: file.id,
+ preview: file.url,
+ })),
+ isDragging: false,
+ errors: [],
+ })
+
+ const inputRef = useRef(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) => {
+ e.preventDefault()
+ e.stopPropagation()
+ setState((prev) => ({ ...prev, isDragging: true }))
+ }, [])
+
+ const handleDragLeave = useCallback((e: DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+
+ if (e.currentTarget.contains(e.relatedTarget as Node)) {
+ return
+ }
+
+ setState((prev) => ({ ...prev, isDragging: false }))
+ }, [])
+
+ const handleDragOver = useCallback((e: DragEvent) => {
+ e.preventDefault()
+ e.stopPropagation()
+ }, [])
+
+ const handleDrop = useCallback(
+ (e: DragEvent) => {
+ 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) => {
+ 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 = {}) => {
+ 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]
+}
\ No newline at end of file
diff --git a/apps/web/src/lib/ui-surface.ts b/apps/web/src/lib/ui-surface.ts
index c0f58b1..76d5e57 100644
--- a/apps/web/src/lib/ui-surface.ts
+++ b/apps/web/src/lib/ui-surface.ts
@@ -1,27 +1,26 @@
/**
* 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.
+ * KPI tiles: **stats-12** pattern via `KpiStatGrid` / `SectionCards` (Frame + icon tile + badge).
+ * Panels and data grids: shadcn Card via `PanelCard` / `DataGridCard`.
*
- * ReUI Frame blocks from registry are adapted into Card composition when installed.
- *
- * @see https://reui.io/blocks — card category
+ * @see https://reui.io/blocks — stats-12, dashboard-1
* @see `.agents/skills/shadcn-react/SKILL.md`
*/
export const UI_SURFACE = 'card' 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 (3–6 tiles). */
+export const kpiStatGridClassName = '@container w-full'
-/** Compact 6-tile KPI row on dashboard overview. */
-export const dashboardKpiGridClassName =
- 'grid grid-cols-2 gap-3 sm:grid-cols-3 xl:grid-cols-6'
+/** @deprecated Use kpiStatGridClassName — kept for legacy imports. */
+export const dashboardKpiGridClassName = kpiStatGridClassName
-/** Grid for dashboard-5 style KPI sparkline row (4 columns at xl). */
+/** @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'
diff --git a/apps/web/src/routes/_auth.tsx b/apps/web/src/routes/_auth.tsx
index 91ab327..03229ed 100644
--- a/apps/web/src/routes/_auth.tsx
+++ b/apps/web/src/routes/_auth.tsx
@@ -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,
diff --git a/apps/web/src/routes/_auth/access.tsx b/apps/web/src/routes/_auth/access.tsx
index 03394aa..74ea51b 100644
--- a/apps/web/src/routes/_auth/access.tsx
+++ b/apps/web/src/routes/_auth/access.tsx
@@ -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: ,
- hint: 'в tenant',
+ badge: (
+
+ в tenant
+
+ ),
},
{
label: 'Активных',
value: activeCount,
icon: ,
- hint: 'не отозваны',
+ badge: (
+
+ не отозваны
+
+ ),
},
{
label: 'Отозванных',
value: revokedCount,
icon: ,
- hint: 'revoked',
variant: revokedCount > 0 ? 'warning' : 'default',
+ badge: (
+ 0 ? 'warning-light' : 'outline'} size="sm">
+ revoked
+
+ ),
},
],
[keys.length, activeCount, revokedCount],
@@ -96,7 +109,11 @@ function AccessComponent() {
) : (
Не удалось определить сессию. Укажите токен в{' '}
-
+
настройках
{' '}
(для dev-окружения — dev при включённом demo-seed).
diff --git a/apps/web/src/routes/_auth/directories.tsx b/apps/web/src/routes/_auth/directories.tsx
index 15a9715..aa84716 100644
--- a/apps/web/src/routes/_auth/directories.tsx
+++ b/apps/web/src/routes/_auth/directories.tsx
@@ -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: ,
- hint: 'теги префиксов в AS- и CDN-модулях',
+ badge: (
+
+ теги префиксов
+
+ ),
},
{
label: 'DoH профили',
value: dohProfiles.length,
icon: ,
- hint: 'резолвинг доменных модулей',
+ badge: (
+
+ резолвинг доменов
+
+ ),
},
{
label: 'Справочники',
value: 'Общие',
icon: ,
- hint: 'используются всеми модулями tenant',
+ badge: (
+
+ все модули tenant
+
+ ),
},
]
diff --git a/apps/web/src/routes/_auth/schedule.tsx b/apps/web/src/routes/_auth/schedule.tsx
index 1ba95dd..4f3a68c 100644
--- a/apps/web/src/routes/_auth/schedule.tsx
+++ b/apps/web/src/routes/_auth/schedule.tsx
@@ -2,9 +2,10 @@ import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, Clock, ListTodo, RefreshCw } from 'lucide-react'
import { toast } from 'sonner'
-import { useEffect, useState } from 'react'
+import { useState } from 'react'
import { Button } from '@evobgp/ui/components/button'
+import { Badge } from '@/components/reui/badge'
import { DataGridCard } from '@/components/data-grid-shell'
import { ScheduleAgendaPanel } from '@/components/schedule/schedule-agenda-panel'
import { ScheduleJobsCard } from '@/components/schedule/schedule-jobs-card'
@@ -36,39 +37,37 @@ function ScheduleComponent() {
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length
- // #region agent log
- useEffect(() => {
- fetch('http://127.0.0.1:7311/ingest/6b35c3ae-1bcd-4c9c-81eb-f157c9347393', {
- method: 'POST',
- headers: { 'Content-Type': 'application/json', 'X-Debug-Session-Id': 'ce85d7' },
- body: JSON.stringify({
- sessionId: 'ce85d7',
- runId: 'pre-fix',
- hypothesisId: 'C',
- location: 'schedule.tsx:mount',
- message: 'schedule page data loaded',
- data: {
- modules: modules.length,
- jobs: jobs.length,
- loading,
- modulesError: modulesQ.isError,
- jobsError: jobsQ.isError,
- },
- timestamp: Date.now(),
- }),
- }).catch(() => {})
- }, [modules.length, jobs.length, loading, modulesQ.isError, jobsQ.isError])
- // #endregion
-
const items: SectionCardItem[] = [
- { label: 'Всего задач', value: jobs.length, icon: , hint: 'в выборке' },
- { label: 'В работе', value: running, icon: , hint: 'в очереди и выполняются' },
+ {
+ label: 'Всего задач',
+ value: jobs.length,
+ icon: ,
+ badge: (
+
+ в выборке
+
+ ),
+ },
+ {
+ label: 'В работе',
+ value: running,
+ icon: ,
+ badge: (
+ 0 ? 'info-light' : 'outline'} size="sm">
+ очередь и выполнение
+
+ ),
+ },
{
label: 'С ошибкой',
value: failed,
icon: ,
- hint: failed > 0 ? 'требуют внимания' : 'без ошибок',
variant: failed > 0 ? 'warning' : 'default',
+ badge: (
+ 0 ? 'warning-light' : 'success-light'} size="sm">
+ {failed > 0 ? 'требуют внимания' : 'без ошибок'}
+
+ ),
},
]
diff --git a/apps/web/src/routes/_auth/settings.tsx b/apps/web/src/routes/_auth/settings.tsx
index 20b8ef2..99bbac4 100644
--- a/apps/web/src/routes/_auth/settings.tsx
+++ b/apps/web/src/routes/_auth/settings.tsx
@@ -1,213 +1,51 @@
-import { createFileRoute, Link, useNavigate, useRouterState } from '@tanstack/react-router'
-import { useQuery, useQueryClient } from '@tanstack/react-query'
-import { Moon, Save, Sun, SunMoon } from 'lucide-react'
-import { useTheme } from 'next-themes'
-import { useEffect, useState } from 'react'
-import { toast } from 'sonner'
+import { createFileRoute } from '@tanstack/react-router'
+import { z } from 'zod'
-import { Badge } from '@/components/reui/badge'
-import { LoadingButton } from '@/components/loading-button'
import { PageHeader } from '@/components/page-header'
-import { PanelCard } from '@/components/panel-card'
-import { SettingsSettingField } from '@/components/settings/settings-setting-field'
-import { DEV_API_TOKEN, normalizeApiToken, setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
-import { authKeys, authSessionQueryOptions } from '@/queries/auth'
-import { Alert, AlertDescription } from '@evobgp/ui/components/alert'
-import { Button } from '@evobgp/ui/components/button'
-import { FieldGroup } from '@evobgp/ui/components/field'
-import { Input } from '@evobgp/ui/components/input'
+import { SettingsPageShell } from '@/components/settings/settings-page-shell'
import {
- ToggleGroup,
- ToggleGroupItem,
-} from '@evobgp/ui/components/toggle-group'
+ 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_OPTIONS = [
- { value: 'light', label: 'Светлая', icon: Sun },
- { value: 'dark', label: 'Тёмная', icon: Moon },
- { value: 'system', label: 'Система', icon: SunMoon },
-] 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 handleTabChange(nextTab: SettingsTab) {
+ void navigate({
+ search: (prev) => ({
+ ...prev,
+ tab: nextTab,
+ }),
+ replace: true,
+ })
}
- function saveTokenHandler() {
- void applyToken(token)
- }
-
- function useDevToken() {
- void applyToken(DEV_API_TOKEN)
- }
-
- const showSessionRow = Boolean(session || sessionError)
- const sessionErrorMessage =
- sessionQueryError instanceof Error
- ? sessionQueryError.message
- : 'Не удалось проверить сессию'
-
return (
-
+
- {tokenRequired ? (
-
-
- Для доступа к разделу нужен API-токен. Сохраните токен ниже или нажмите «Использовать
- dev».
-
-
- ) : null}
-
-
-
-
-
- Сохранить токен
-
-
- }
- >
-
-
- Ключ для заголовка Authorization. Управление
- ключами tenant — в разделе{' '}
-
- Права доступа
-
- .
- >
- }
- badge={{ label: 'localStorage', variant: 'outline' }}
- labelFor="token"
- last={!showSessionRow}
- >
- setTokenValue(e.target.value)}
- placeholder="dev или API-ключ"
- />
-
-
- {showSessionRow ? (
-
- {session ? (
-
-
- Tenant:{' '}
- {session.tenant_id}
-
-
- Роль: {session.role}
-
-
- ) : (
-
- {sessionErrorMessage}. Для токена dev нужен
- demo-seed (EVOBGP_SEED_DEMO ≠ 0) и запущенный
- API.
-
- )}
-
- ) : null}
-
-
-
-
-
-
- {
- 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 (
-
-
- {option.label}
-
- )
- })}
-
-
-
-
+
)
}
diff --git a/apps/web/tsconfig.tsbuildinfo b/apps/web/tsconfig.tsbuildinfo
index c2bf8d6..457ffe2 100644
--- a/apps/web/tsconfig.tsbuildinfo
+++ b/apps/web/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/badge-tabs.tsx","./src/components/category-badge.tsx","./src/components/confirm-dialog.tsx","./src/components/data-grid-cell.tsx","./src/components/data-grid-shell.tsx","./src/components/data-grid-toolbar.tsx","./src/components/drawer-layout.tsx","./src/components/empty-state.tsx","./src/components/form-drawer.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/panel-card.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/select-field.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/access-api-keys-grid.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/analytics/analytics-activity-list.tsx","./src/components/analytics/analytics-card-shell.tsx","./src/components/analytics/analytics-kpi-row.tsx","./src/components/analytics/analytics-progress.tsx","./src/components/analytics/analytics-segment-control.tsx","./src/components/analytics/chart-bar-strip.tsx","./src/components/analytics/chart-donut-metric.tsx","./src/components/analytics/dashboard-network-capacity-card.tsx","./src/components/analytics/dashboard-operations-flow-card.tsx","./src/components/analytics/dashboard-platform-card.tsx","./src/components/analytics/index.ts","./src/components/analytics/monitoring-health-card.tsx","./src/components/analytics/network-overview-analytics-card.tsx","./src/components/analytics/operations-analytics-card.tsx","./src/components/dashboard/card-dot-field.tsx","./src/components/dashboard/dashboard-activity-timeline.tsx","./src/components/dashboard/dashboard-frame-panel.tsx","./src/components/dashboard/dashboard-kpi-grid.tsx","./src/components/dashboard/dashboard-kpi-sparkline-row.tsx","./src/components/dashboard/dashboard-modules-grid.tsx","./src/components/dashboard/dashboard-network-health.tsx","./src/components/dashboard/dashboard-network-panel.tsx","./src/components/dashboard/dashboard-operations-breakdown.tsx","./src/components/dashboard/dashboard-quick-link-card.tsx","./src/components/dashboard/dashboard-quick-links.tsx","./src/components/dashboard/dashboard-recent-jobs-grid.tsx","./src/components/dashboard/dashboard-recent-revisions-grid.tsx","./src/components/directories/directories-communities-grid.tsx","./src/components/directories/directories-doh-grid.tsx","./src/components/examples/c-input-group-37.tsx","./src/components/examples/c-select-4.tsx","./src/components/examples/c-tabs-6.tsx","./src/components/examples/c-tabs-7.tsx","./src/components/firewall/firewall-clients-grid.tsx","./src/components/firewall/firewall-rule-create-dialog.tsx","./src/components/firewall/firewall-rules-grid.tsx","./src/components/layout/app-shell.tsx","./src/components/layout/command-palette.tsx","./src/components/layout/system-monitor-popover.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-grid.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/modules/modules-list-grid.tsx","./src/components/monitoring/monitoring-ready-grid.tsx","./src/components/network/network-peers-card.tsx","./src/components/network/network-peers-grid.tsx","./src/components/network/network-speakers-card.tsx","./src/components/network/network-speakers-grid.tsx","./src/components/network/peer-form-dialog.tsx","./src/components/network/speaker-form-dialog.tsx","./src/components/operations/operations-jobs-card.tsx","./src/components/operations/operations-jobs-grid.tsx","./src/components/operations/operations-revisions-grid.tsx","./src/components/patterns/donut-breakdown-card.tsx","./src/components/patterns/illustrated-empty-state.tsx","./src/components/patterns/index.ts","./src/components/patterns/kpi-sparkline-card.tsx","./src/components/patterns/metric-tone-styles.ts","./src/components/patterns/panel-corners.tsx","./src/components/patterns/projects-empty-state.tsx","./src/components/patterns/segmented-progress-card.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/frame.tsx","./src/components/reui/icon-stack.tsx","./src/components/reui/number-field.tsx","./src/components/reui/rating.tsx","./src/components/reui/timeline.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/components/schedule/schedule-agenda-panel.tsx","./src/components/schedule/schedule-calendar-view.tsx","./src/components/schedule/schedule-jobs-card.tsx","./src/components/schedule/schedule-jobs-grid.tsx","./src/components/schedule/schedule-modules-grid.tsx","./src/components/settings/settings-kv-grid.tsx","./src/components/settings/settings-setting-field.tsx","./src/components/ui/svgs/anthropicblack.tsx","./src/components/ui/svgs/anthropicwhite.tsx","./src/components/ui/svgs/convex.tsx","./src/components/ui/svgs/discord.tsx","./src/components/ui/svgs/gemini.tsx","./src/components/ui/svgs/googlecloud.tsx","./src/components/ui/svgs/hono.tsx","./src/components/ui/svgs/loom.tsx","./src/components/ui/svgs/mintlify.tsx","./src/components/ui/svgs/n8n.tsx","./src/components/ui/svgs/neon.tsx","./src/components/ui/svgs/openai.tsx","./src/components/ui/svgs/openaidark.tsx","./src/components/ui/svgs/paper.tsx","./src/components/ui/svgs/planetscale.tsx","./src/components/ui/svgs/planetscaledark.tsx","./src/components/ui/svgs/prisma.tsx","./src/components/ui/svgs/prismadark.tsx","./src/components/ui/svgs/remixdark.tsx","./src/components/ui/svgs/remixlight.tsx","./src/components/ui/svgs/resendiconblack.tsx","./src/components/ui/svgs/resendiconwhite.tsx","./src/components/ui/svgs/slack.tsx","./src/components/ui/svgs/stripe.tsx","./src/components/ui/svgs/supabase.tsx","./src/components/ui/svgs/zoom.tsx","./src/hooks/use-client-data-grid.ts","./src/hooks/use-copy-to-clipboard.ts","./src/hooks/use-mobile.ts","./src/lib/api-client.ts","./src/lib/data-grid-defaults.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/ui-surface.ts","./src/lib/access/api-key-labels.ts","./src/lib/metrics/deployment-progress.ts","./src/lib/metrics/index.ts","./src/lib/metrics/job-status-breakdown.ts","./src/lib/metrics/module-type-breakdown.ts","./src/lib/metrics/peer-capacity-bars.ts","./src/lib/metrics/peer-session-breakdown.ts","./src/lib/metrics/readiness-breakdown.ts","./src/lib/metrics/recent-platform-activity.ts","./src/lib/metrics/types.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/firewall.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/firewall.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"}
\ No newline at end of file
+{"root":["./src/main.tsx","./src/routetree.gen.ts","./src/components/badge-tabs.tsx","./src/components/category-badge.tsx","./src/components/confirm-dialog.tsx","./src/components/data-grid-cell.tsx","./src/components/data-grid-shell.tsx","./src/components/data-grid-toolbar.tsx","./src/components/drawer-layout.tsx","./src/components/empty-state.tsx","./src/components/form-drawer.tsx","./src/components/kpi-stat-grid.tsx","./src/components/loading-button.tsx","./src/components/mode-toggle.tsx","./src/components/page-header.tsx","./src/components/page-shell.tsx","./src/components/panel-card.tsx","./src/components/query-state.tsx","./src/components/section-cards.tsx","./src/components/select-field.tsx","./src/components/skeletons.tsx","./src/components/status-badge.tsx","./src/components/theme-provider.tsx","./src/components/truncated-text.tsx","./src/components/access/access-api-keys-card.tsx","./src/components/access/access-api-keys-grid.tsx","./src/components/access/api-key-create-dialog.tsx","./src/components/access/api-key-token-dialog.tsx","./src/components/analytics/analytics-activity-list.tsx","./src/components/analytics/analytics-card-shell.tsx","./src/components/analytics/analytics-kpi-row.tsx","./src/components/analytics/analytics-progress.tsx","./src/components/analytics/analytics-segment-control.tsx","./src/components/analytics/chart-bar-strip.tsx","./src/components/analytics/chart-donut-metric.tsx","./src/components/analytics/dashboard-network-capacity-card.tsx","./src/components/analytics/dashboard-operations-flow-card.tsx","./src/components/analytics/dashboard-platform-card.tsx","./src/components/analytics/index.ts","./src/components/analytics/monitoring-health-card.tsx","./src/components/analytics/network-overview-analytics-card.tsx","./src/components/analytics/operations-analytics-card.tsx","./src/components/dashboard/card-dot-field.tsx","./src/components/dashboard/dashboard-activity-timeline.tsx","./src/components/dashboard/dashboard-frame-panel.tsx","./src/components/dashboard/dashboard-kpi-grid.tsx","./src/components/dashboard/dashboard-kpi-sparkline-row.tsx","./src/components/dashboard/dashboard-modules-grid.tsx","./src/components/dashboard/dashboard-network-health.tsx","./src/components/dashboard/dashboard-network-panel.tsx","./src/components/dashboard/dashboard-operations-breakdown.tsx","./src/components/dashboard/dashboard-quick-link-card.tsx","./src/components/dashboard/dashboard-quick-links.tsx","./src/components/dashboard/dashboard-recent-jobs-grid.tsx","./src/components/dashboard/dashboard-recent-revisions-grid.tsx","./src/components/directories/directories-communities-grid.tsx","./src/components/directories/directories-doh-grid.tsx","./src/components/examples/c-input-group-37.tsx","./src/components/examples/c-select-4.tsx","./src/components/examples/c-tabs-2.tsx","./src/components/examples/c-tabs-6.tsx","./src/components/examples/c-tabs-7.tsx","./src/components/firewall/firewall-clients-grid.tsx","./src/components/firewall/firewall-rule-create-dialog.tsx","./src/components/firewall/firewall-rules-grid.tsx","./src/components/layout/app-shell.tsx","./src/components/layout/command-palette.tsx","./src/components/layout/system-monitor-popover.tsx","./src/components/modules/community-select.tsx","./src/components/modules/module-as-entry-dialog.tsx","./src/components/modules/module-cdn-source-dialog.tsx","./src/components/modules/module-domain-entry-dialog.tsx","./src/components/modules/module-entries-grid.tsx","./src/components/modules/module-entries-section.tsx","./src/components/modules/module-ip-range-entry-dialog.tsx","./src/components/modules/module-kpi-cards.tsx","./src/components/modules/modules-list-grid.tsx","./src/components/monitoring/monitoring-ready-grid.tsx","./src/components/network/network-peers-card.tsx","./src/components/network/network-peers-grid.tsx","./src/components/network/network-speakers-card.tsx","./src/components/network/network-speakers-grid.tsx","./src/components/network/peer-form-dialog.tsx","./src/components/network/speaker-form-dialog.tsx","./src/components/operations/operations-jobs-card.tsx","./src/components/operations/operations-jobs-grid.tsx","./src/components/operations/operations-revisions-grid.tsx","./src/components/patterns/donut-breakdown-card.tsx","./src/components/patterns/illustrated-empty-state.tsx","./src/components/patterns/index.ts","./src/components/patterns/kpi-sparkline-card.tsx","./src/components/patterns/metric-tone-styles.ts","./src/components/patterns/panel-corners.tsx","./src/components/patterns/projects-empty-state.tsx","./src/components/patterns/segmented-progress-card.tsx","./src/components/reui/alert.tsx","./src/components/reui/autocomplete.tsx","./src/components/reui/badge.tsx","./src/components/reui/date-selector.tsx","./src/components/reui/filters.tsx","./src/components/reui/frame.tsx","./src/components/reui/icon-stack.tsx","./src/components/reui/number-field.tsx","./src/components/reui/rating.tsx","./src/components/reui/timeline.tsx","./src/components/reui/data-grid/data-grid-column-filter.tsx","./src/components/reui/data-grid/data-grid-column-header.tsx","./src/components/reui/data-grid/data-grid-column-visibility.tsx","./src/components/reui/data-grid/data-grid-pagination.tsx","./src/components/reui/data-grid/data-grid-scroll-area.tsx","./src/components/reui/data-grid/data-grid-table-dnd-rows.tsx","./src/components/reui/data-grid/data-grid-table-dnd.tsx","./src/components/reui/data-grid/data-grid-table-virtual.tsx","./src/components/reui/data-grid/data-grid-table.tsx","./src/components/reui/data-grid/data-grid.tsx","./src/components/schedule/schedule-agenda-panel.tsx","./src/components/schedule/schedule-calendar-view.tsx","./src/components/schedule/schedule-jobs-card.tsx","./src/components/schedule/schedule-jobs-grid.tsx","./src/components/schedule/schedule-modules-grid.tsx","./src/components/settings/appearance-settings-tab.tsx","./src/components/settings/connection-settings-tab.tsx","./src/components/settings/sections-settings-tab.tsx","./src/components/settings/session-settings-tab.tsx","./src/components/settings/settings-kv-grid.tsx","./src/components/settings/settings-page-shell.tsx","./src/components/settings/settings-setting-field.tsx","./src/components/settings/settings-tabs-data.tsx","./src/components/ui/svgs/anthropicblack.tsx","./src/components/ui/svgs/anthropicwhite.tsx","./src/components/ui/svgs/convex.tsx","./src/components/ui/svgs/discord.tsx","./src/components/ui/svgs/gemini.tsx","./src/components/ui/svgs/googlecloud.tsx","./src/components/ui/svgs/hono.tsx","./src/components/ui/svgs/loom.tsx","./src/components/ui/svgs/mintlify.tsx","./src/components/ui/svgs/n8n.tsx","./src/components/ui/svgs/neon.tsx","./src/components/ui/svgs/openai.tsx","./src/components/ui/svgs/openaidark.tsx","./src/components/ui/svgs/paper.tsx","./src/components/ui/svgs/planetscale.tsx","./src/components/ui/svgs/planetscaledark.tsx","./src/components/ui/svgs/prisma.tsx","./src/components/ui/svgs/prismadark.tsx","./src/components/ui/svgs/remixdark.tsx","./src/components/ui/svgs/remixlight.tsx","./src/components/ui/svgs/resendiconblack.tsx","./src/components/ui/svgs/resendiconwhite.tsx","./src/components/ui/svgs/slack.tsx","./src/components/ui/svgs/stripe.tsx","./src/components/ui/svgs/supabase.tsx","./src/components/ui/svgs/zoom.tsx","./src/hooks/use-client-data-grid.ts","./src/hooks/use-copy-to-clipboard.ts","./src/hooks/use-file-upload.ts","./src/hooks/use-mobile.ts","./src/lib/api-client.ts","./src/lib/data-grid-defaults.ts","./src/lib/queryclient.ts","./src/lib/router.ts","./src/lib/ui-labels.ts","./src/lib/ui-surface.ts","./src/lib/access/api-key-labels.ts","./src/lib/metrics/deployment-progress.ts","./src/lib/metrics/index.ts","./src/lib/metrics/job-status-breakdown.ts","./src/lib/metrics/module-type-breakdown.ts","./src/lib/metrics/peer-capacity-bars.ts","./src/lib/metrics/peer-session-breakdown.ts","./src/lib/metrics/readiness-breakdown.ts","./src/lib/metrics/recent-platform-activity.ts","./src/lib/metrics/types.ts","./src/lib/modules/display.ts","./src/lib/modules/helpers.ts","./src/queries/api-keys.ts","./src/queries/auth.ts","./src/queries/directories.ts","./src/queries/firewall.ts","./src/queries/modules.ts","./src/queries/monitoring.ts","./src/queries/network.ts","./src/queries/operations.ts","./src/queries/overview.ts","./src/queries/settings.ts","./src/routes/__root.tsx","./src/routes/_auth.tsx","./src/routes/index.tsx","./src/routes/_auth/access.tsx","./src/routes/_auth/dashboard.tsx","./src/routes/_auth/directories.tsx","./src/routes/_auth/firewall.tsx","./src/routes/_auth/monitoring.tsx","./src/routes/_auth/network.tsx","./src/routes/_auth/operations.tsx","./src/routes/_auth/schedule.tsx","./src/routes/_auth/settings.tsx","./src/routes/_auth/tenant-settings.tsx","./src/routes/_auth/modules/$moduleid.tsx","./src/routes/_auth/modules/index.tsx","./src/routes/_auth/modules/new.tsx","./src/types/api.ts","./vite.config.ts"],"version":"5.9.3"}
\ No newline at end of file
diff --git a/packages/ui/src/components/avatar.tsx b/packages/ui/src/components/avatar.tsx
index 09be2ca..f5c16ee 100644
--- a/packages/ui/src/components/avatar.tsx
+++ b/packages/ui/src/components/avatar.tsx
@@ -1,3 +1,5 @@
+"use client"
+
import * as React from "react"
import { Avatar as AvatarPrimitive } from "@base-ui/react/avatar"
diff --git a/packages/ui/src/components/field.tsx b/packages/ui/src/components/field.tsx
index b5a8f0b..04ed7e9 100644
--- a/packages/ui/src/components/field.tsx
+++ b/packages/ui/src/components/field.tsx
@@ -1,3 +1,5 @@
+"use client"
+
import { useMemo } from "react"
import { cva, type VariantProps } from "class-variance-authority"
diff --git a/packages/ui/src/components/input-group.tsx b/packages/ui/src/components/input-group.tsx
index a118e5c..a603090 100644
--- a/packages/ui/src/components/input-group.tsx
+++ b/packages/ui/src/components/input-group.tsx
@@ -1,5 +1,3 @@
-"use client"
-
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
diff --git a/packages/ui/src/components/select.tsx b/packages/ui/src/components/select.tsx
index a80f95a..e2d6500 100644
--- a/packages/ui/src/components/select.tsx
+++ b/packages/ui/src/components/select.tsx
@@ -1,5 +1,3 @@
-"use client"
-
import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
diff --git a/packages/ui/src/components/separator.tsx b/packages/ui/src/components/separator.tsx
index 13e3b9a..f77b441 100644
--- a/packages/ui/src/components/separator.tsx
+++ b/packages/ui/src/components/separator.tsx
@@ -1,3 +1,5 @@
+"use client"
+
import { Separator as SeparatorPrimitive } from "@base-ui/react/separator"
import { cn } from "@evobgp/ui/lib/utils"
diff --git a/packages/ui/src/components/switch.tsx b/packages/ui/src/components/switch.tsx
index ec7c5c9..7089843 100644
--- a/packages/ui/src/components/switch.tsx
+++ b/packages/ui/src/components/switch.tsx
@@ -1,3 +1,5 @@
+"use client"
+
import { Switch as SwitchPrimitive } from "@base-ui/react/switch"
import { cn } from "@evobgp/ui/lib/utils"
diff --git a/packages/ui/src/components/tabs.tsx b/packages/ui/src/components/tabs.tsx
index 7d84d61..3b0ded1 100644
--- a/packages/ui/src/components/tabs.tsx
+++ b/packages/ui/src/components/tabs.tsx
@@ -13,9 +13,9 @@ function Tabs({
return (