Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
55eb2a6c89 |
@@ -0,0 +1,183 @@
|
||||
import {
|
||||
AlertTriangle,
|
||||
Boxes,
|
||||
ListChecks,
|
||||
Network,
|
||||
ServerCog,
|
||||
Share2,
|
||||
} from 'lucide-react'
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
function buildKpis({
|
||||
modules,
|
||||
peers,
|
||||
speakers,
|
||||
jobs,
|
||||
loading,
|
||||
}: {
|
||||
modules: ModuleRow[]
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
jobs: JobRow[]
|
||||
loading?: boolean
|
||||
}): KpiCard[] {
|
||||
const enabledModules = modules.filter((m) => m.enabled !== false).length
|
||||
const network = aggregateNetworkMetrics(peers, speakers)
|
||||
const peersEnabled = network.peersEnabled
|
||||
const bgpPct =
|
||||
peersEnabled > 0 ? Math.round((network.peersEstablished / peersEnabled) * 100) : null
|
||||
const running = runningJobCount(jobs)
|
||||
const failedJobs = jobs.filter((j) =>
|
||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||
).length
|
||||
const offlineSpeakers = Math.max(0, speakers.length - network.speakersOnline)
|
||||
const riskCount = network.peersMismatch + failedJobs + offlineSpeakers
|
||||
|
||||
return [
|
||||
{
|
||||
icon: <Boxes aria-hidden />,
|
||||
iconClass: 'text-primary',
|
||||
value: loading ? '—' : `${enabledModules}/${modules.length || 0}`,
|
||||
label: 'Модули активны',
|
||||
badge: (
|
||||
<Badge variant="primary-light" size="sm">
|
||||
{loading ? '…' : `${modules.length} всего`}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <Network aria-hidden />,
|
||||
iconClass: 'text-info',
|
||||
value: loading || bgpPct === null ? '—' : `${bgpPct}%`,
|
||||
label: 'BGP готовность',
|
||||
badge: (
|
||||
<Badge
|
||||
variant={
|
||||
bgpPct !== null && bgpPct >= 90
|
||||
? 'success-light'
|
||||
: bgpPct !== null && bgpPct < 70
|
||||
? 'warning-light'
|
||||
: 'outline'
|
||||
}
|
||||
size="sm"
|
||||
>
|
||||
{loading || bgpPct === null
|
||||
? 'нет включённых пиров'
|
||||
: `${network.peersEstablished} установлено`}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <Share2 aria-hidden />,
|
||||
iconClass: 'text-success',
|
||||
value: loading ? '—' : `${network.peersEstablished}/${peersEnabled}`,
|
||||
label: 'Пиры Established',
|
||||
badge: (
|
||||
<Badge variant="success-light" size="sm">
|
||||
{loading ? '…' : `${network.peersTotal} в каталоге`}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <ServerCog aria-hidden />,
|
||||
iconClass: 'text-warning',
|
||||
value: loading ? '—' : `${network.speakersOnline}/${network.speakersTotal}`,
|
||||
label: 'Спикеры online',
|
||||
badge: (
|
||||
<Badge
|
||||
variant={network.speakersOnline === network.speakersTotal ? 'success-light' : 'warning-light'}
|
||||
size="sm"
|
||||
>
|
||||
{loading ? '…' : 'live-снимок'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <ListChecks aria-hidden />,
|
||||
iconClass: 'text-focus',
|
||||
value: loading ? '—' : String(running),
|
||||
label: 'Активные задачи',
|
||||
badge: (
|
||||
<Badge variant={running > 0 ? 'info-light' : 'outline'} size="sm">
|
||||
{loading ? '…' : `${jobs.length} в выборке`}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
icon: <AlertTriangle aria-hidden />,
|
||||
iconClass: 'text-destructive',
|
||||
value: loading ? '—' : String(riskCount),
|
||||
label: 'Риски',
|
||||
badge: (
|
||||
<Badge variant={riskCount > 0 ? 'destructive-light' : 'success-light'} size="sm">
|
||||
{loading
|
||||
? '…'
|
||||
: riskCount > 0
|
||||
? `${failedJobs} задач · ${network.peersMismatch} расхождений`
|
||||
: 'в норме'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
export function DashboardKpiGrid({
|
||||
modules,
|
||||
peers,
|
||||
speakers,
|
||||
jobs,
|
||||
loading,
|
||||
}: {
|
||||
modules: ModuleRow[]
|
||||
peers: PeerRow[]
|
||||
speakers: SpeakerRow[]
|
||||
jobs: JobRow[]
|
||||
loading?: boolean
|
||||
}) {
|
||||
const cards = buildKpis({ modules, peers, speakers, jobs, loading })
|
||||
|
||||
return (
|
||||
<section aria-label="KPI обзора" className={dashboardKpiGridClassName}>
|
||||
{cards.map((card) => (
|
||||
<Card key={card.label} size="sm" className="gap-0">
|
||||
<CardContent className={cn(kpiCardContentClassName, 'gap-3 p-4')}>
|
||||
<Item
|
||||
className={cn(
|
||||
'border-background bg-muted flex size-9 items-center justify-center border-2 p-0 shadow-[0_1px_3px_0_rgba(0,0,0,0.14)] dark:border [&_svg]:size-4',
|
||||
card.iconClass,
|
||||
)}
|
||||
>
|
||||
<ItemMedia variant="icon" className="size-auto">
|
||||
{card.icon}
|
||||
</ItemMedia>
|
||||
</Item>
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-foreground text-xl leading-none font-bold tabular-nums">
|
||||
{card.value}
|
||||
</div>
|
||||
<div className="text-muted-foreground text-xs font-medium">{card.label}</div>
|
||||
</div>
|
||||
{card.badge}
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -22,7 +22,7 @@ export function SettingsSettingField({
|
||||
contentClassName,
|
||||
}: {
|
||||
title: string
|
||||
description: string
|
||||
description: ReactNode
|
||||
badge?: { label: string; variant: ComponentProps<typeof Badge>['variant'] }
|
||||
children: ReactNode
|
||||
last?: boolean
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||
import { panelCardInsetClassName } from '@/components/panel-card'
|
||||
import {
|
||||
chartPanelGridClassName,
|
||||
dashboardKpiGridClassName,
|
||||
dashboardMainSidebarClassName,
|
||||
kpiGridClassName,
|
||||
} from '@/lib/ui-surface'
|
||||
@@ -24,9 +25,9 @@ export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
|
||||
export function AnalyticsDashboardSkeleton() {
|
||||
return (
|
||||
<div className="@container flex flex-col gap-4">
|
||||
<div className={kpiGridClassName}>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<Skeleton key={`kpi-${i}`} className="h-36 w-full rounded-xl" />
|
||||
<div className={dashboardKpiGridClassName}>
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={`kpi-${i}`} className="h-28 w-full rounded-xl" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -15,6 +15,10 @@ 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'
|
||||
|
||||
/** 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'
|
||||
|
||||
/** Grid for dashboard-5 style KPI sparkline row (4 columns at xl). */
|
||||
export const kpiGridClassName =
|
||||
'grid grid-cols-1 gap-4 @3xl:grid-cols-2 @6xl:grid-cols-4'
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Skeleton } from '@evobgp/ui/components/skeleton'
|
||||
|
||||
import { DashboardActivityTimeline } from '@/components/dashboard/dashboard-activity-timeline'
|
||||
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
|
||||
import { DashboardKpiSparklineRow } from '@/components/dashboard/dashboard-kpi-sparkline-row'
|
||||
import { DashboardKpiGrid } from '@/components/dashboard/dashboard-kpi-grid'
|
||||
import { DashboardModulesGrid } from '@/components/dashboard/dashboard-modules-grid'
|
||||
import { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health'
|
||||
import { DashboardOperationsBreakdown } from '@/components/dashboard/dashboard-operations-breakdown'
|
||||
@@ -92,7 +92,7 @@ function DashboardComponent() {
|
||||
<AnalyticsDashboardSkeleton />
|
||||
) : (
|
||||
<>
|
||||
<DashboardKpiSparklineRow
|
||||
<DashboardKpiGrid
|
||||
modules={modules}
|
||||
peers={peers}
|
||||
speakers={speakers}
|
||||
|
||||
@@ -1,30 +1,34 @@
|
||||
import { createFileRoute, useNavigate, useRouterState } from '@tanstack/react-router'
|
||||
import { createFileRoute, Link, useNavigate, useRouterState } from '@tanstack/react-router'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Alert, AlertDescription } from '@evobgp/ui/components/alert'
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
import { Input } from '@evobgp/ui/components/input'
|
||||
import { Label } from '@evobgp/ui/components/label'
|
||||
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { SelectField } from '@/components/select-field'
|
||||
import { DEV_API_TOKEN, normalizeApiToken, setToken, TOKEN_STORAGE_KEY } from '@/lib/api-client'
|
||||
import { authKeys, authSessionQueryOptions } from '@/queries/auth'
|
||||
import { toast } from 'sonner'
|
||||
import { Save } from 'lucide-react'
|
||||
import { Moon, Save, Sun, SunMoon } from 'lucide-react'
|
||||
import { useTheme } from 'next-themes'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { Badge } from '@/components/reui/badge'
|
||||
import { LoadingButton } from '@/components/loading-button'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { 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 {
|
||||
ToggleGroup,
|
||||
ToggleGroupItem,
|
||||
} from '@evobgp/ui/components/toggle-group'
|
||||
|
||||
export const Route = createFileRoute('/_auth/settings')({
|
||||
component: SettingsComponent,
|
||||
})
|
||||
|
||||
const THEME_SELECT_ITEMS = [
|
||||
{ value: 'light', label: 'Светлая' },
|
||||
{ value: 'dark', label: 'Тёмная' },
|
||||
{ value: 'system', label: 'Как в системе' },
|
||||
const THEME_OPTIONS = [
|
||||
{ value: 'light', label: 'Светлая', icon: Sun },
|
||||
{ value: 'dark', label: 'Тёмная', icon: Moon },
|
||||
{ value: 'system', label: 'Система', icon: SunMoon },
|
||||
] as const
|
||||
|
||||
function SettingsComponent() {
|
||||
@@ -66,6 +70,12 @@ function SettingsComponent() {
|
||||
void applyToken(DEV_API_TOKEN)
|
||||
}
|
||||
|
||||
const showSessionRow = Boolean(session || sessionError)
|
||||
const sessionErrorMessage =
|
||||
sessionQueryError instanceof Error
|
||||
? sessionQueryError.message
|
||||
: 'Не удалось проверить сессию'
|
||||
|
||||
return (
|
||||
<div className="mx-auto flex max-w-3xl flex-col gap-6">
|
||||
<PageHeader
|
||||
@@ -76,18 +86,45 @@ function SettingsComponent() {
|
||||
{tokenRequired ? (
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
Для доступа к разделу нужен API-токен. Сохраните токен ниже или нажмите «Использовать dev».
|
||||
Для доступа к разделу нужен API-токен. Сохраните токен ниже или нажмите «Использовать
|
||||
dev».
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<PanelCard
|
||||
title="Подключение к API"
|
||||
description="Токен хранится только в этом браузере (localStorage). Управление ключами tenant — в разделе «Права доступа»."
|
||||
contentClassName="flex flex-col gap-4 py-4"
|
||||
description="Токен хранится только в этом браузере (localStorage)."
|
||||
contentClassName="p-0"
|
||||
footer={
|
||||
<div className="flex w-full flex-wrap justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={useDevToken}>
|
||||
Использовать dev
|
||||
</Button>
|
||||
<LoadingButton onClick={saveTokenHandler}>
|
||||
<Save />
|
||||
Сохранить токен
|
||||
</LoadingButton>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="token">Токен для запросов</Label>
|
||||
<FieldGroup className="gap-0">
|
||||
<SettingsSettingField
|
||||
title="Токен для запросов"
|
||||
description={
|
||||
<>
|
||||
Ключ для заголовка <code className="text-xs">Authorization</code>. Управление
|
||||
ключами tenant — в разделе{' '}
|
||||
<Link to="/access" className="text-primary underline-offset-4 hover:underline">
|
||||
Права доступа
|
||||
</Link>
|
||||
.
|
||||
</>
|
||||
}
|
||||
badge={{ label: 'localStorage', variant: 'outline' }}
|
||||
labelFor="token"
|
||||
last={!showSessionRow}
|
||||
>
|
||||
<Input
|
||||
id="token"
|
||||
type="password"
|
||||
@@ -96,47 +133,80 @@ function SettingsComponent() {
|
||||
onChange={(e) => setTokenValue(e.target.value)}
|
||||
placeholder="dev или API-ключ"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<LoadingButton onClick={saveTokenHandler}>
|
||||
<Save />
|
||||
Сохранить токен
|
||||
</LoadingButton>
|
||||
<Button type="button" variant="outline" onClick={useDevToken}>
|
||||
Использовать dev
|
||||
</Button>
|
||||
</div>
|
||||
{session ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Активная сессия: tenant <code className="font-mono">{session.tenant_id}</code>, роль{' '}
|
||||
<code className="font-mono">{session.role}</code>.
|
||||
</p>
|
||||
) : null}
|
||||
{sessionError ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{sessionQueryError instanceof Error
|
||||
? sessionQueryError.message
|
||||
: 'Не удалось проверить сессию'}
|
||||
. Для токена <code className="font-mono">dev</code> нужен demo-seed (
|
||||
<code className="text-xs">EVOBGP_SEED_DEMO</code> ≠ 0) и запущенный API.
|
||||
</p>
|
||||
</SettingsSettingField>
|
||||
|
||||
{showSessionRow ? (
|
||||
<SettingsSettingField
|
||||
title="Статус сессии"
|
||||
description={
|
||||
session
|
||||
? 'Проверка токена через GET /v1/auth/session.'
|
||||
: 'Токен сохранён, но сессия не подтверждена API.'
|
||||
}
|
||||
badge={
|
||||
session
|
||||
? { label: session.role, variant: 'success-light' }
|
||||
: { label: 'ошибка', variant: 'destructive-light' }
|
||||
}
|
||||
last
|
||||
>
|
||||
{session ? (
|
||||
<div className="text-muted-foreground space-y-1 text-sm">
|
||||
<p>
|
||||
Tenant:{' '}
|
||||
<code className="text-foreground font-mono text-xs">{session.tenant_id}</code>
|
||||
</p>
|
||||
<p>
|
||||
Роль: <Badge variant="info-light" size="sm">{session.role}</Badge>
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-destructive text-sm">
|
||||
{sessionErrorMessage}. Для токена <code className="font-mono">dev</code> нужен
|
||||
demo-seed (<code className="text-xs">EVOBGP_SEED_DEMO</code> ≠ 0) и запущенный
|
||||
API.
|
||||
</p>
|
||||
)}
|
||||
</SettingsSettingField>
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
</PanelCard>
|
||||
|
||||
<PanelCard
|
||||
title="Оформление"
|
||||
description="Тема интерфейса. Быстрый переключатель также доступен в боковой панели."
|
||||
contentClassName="flex flex-col gap-2 py-4"
|
||||
contentClassName="p-0"
|
||||
>
|
||||
<SelectField
|
||||
id="theme-select"
|
||||
label="Тема"
|
||||
items={[...THEME_SELECT_ITEMS]}
|
||||
value={theme ?? 'system'}
|
||||
placeholder="Выберите тему"
|
||||
triggerClassName="max-w-xs"
|
||||
onValueChange={(v) => v && setTheme(v)}
|
||||
/>
|
||||
<FieldGroup className="gap-0">
|
||||
<SettingsSettingField
|
||||
title="Тема"
|
||||
description="Влияет на цветовую схему всех экранов в этом браузере."
|
||||
badge={{ label: 'мгновенно', variant: 'primary-light' }}
|
||||
last
|
||||
>
|
||||
<ToggleGroup
|
||||
multiple={false}
|
||||
value={[theme ?? 'system']}
|
||||
onValueChange={(value) => {
|
||||
if (value.length > 0) setTheme(value[0])
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
aria-label="Тема интерфейса"
|
||||
className="w-full sm:w-auto"
|
||||
>
|
||||
{THEME_OPTIONS.map((option) => {
|
||||
const Icon = option.icon
|
||||
return (
|
||||
<ToggleGroupItem key={option.value} value={option.value} className="gap-1.5">
|
||||
<Icon aria-hidden className="size-3.5" />
|
||||
{option.label}
|
||||
</ToggleGroupItem>
|
||||
)
|
||||
})}
|
||||
</ToggleGroup>
|
||||
</SettingsSettingField>
|
||||
</FieldGroup>
|
||||
</PanelCard>
|
||||
</div>
|
||||
)
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user