Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbcf9d76d9 | ||
|
|
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>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,69 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
import { format, isSameDay, parseISO } from 'date-fns'
|
||||
import { ru } from 'date-fns/locale'
|
||||
import { CalendarDays, Clock } from 'lucide-react'
|
||||
|
||||
import { PanelCard } from '@/components/panel-card'
|
||||
import { IllustratedEmptyState } from '@/components/patterns/illustrated-empty-state'
|
||||
import { PanelCard, panelCardContentFlushClassName } from '@/components/panel-card'
|
||||
import { StatusBadge } from '@/components/status-badge'
|
||||
import { Calendar } from '@evobgp/ui/components/calendar'
|
||||
import { Item } from '@evobgp/ui/components/item'
|
||||
import { ScrollArea } from '@evobgp/ui/components/scroll-area'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evobgp/ui/components/select'
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { jobKindRu } from '@/lib/ui-labels'
|
||||
import type { JobRow } from '@/types/api'
|
||||
|
||||
/** schedule-1 inspired agenda: calendar + day job list. */
|
||||
import { ScheduleCalendarView } from './schedule-calendar-view'
|
||||
|
||||
type JobFilter = 'all' | 'refresh' | 'failed'
|
||||
|
||||
const FILTER_ITEMS: { value: JobFilter; label: string }[] = [
|
||||
{ value: 'all', label: 'Все задачи' },
|
||||
{ value: 'refresh', label: 'Обновление' },
|
||||
{ value: 'failed', label: 'С ошибкой' },
|
||||
]
|
||||
|
||||
function jobTimestamp(job: JobRow): string | undefined {
|
||||
return job.created_at ?? job.started_at ?? job.finished_at ?? undefined
|
||||
}
|
||||
|
||||
function matchesFilter(job: JobRow, filter: JobFilter): boolean {
|
||||
if (filter === 'refresh') return job.kind === 'module_refresh'
|
||||
if (filter === 'failed')
|
||||
return ['failed', 'error', 'cancelled'].includes(job.status.toLowerCase())
|
||||
return true
|
||||
}
|
||||
|
||||
function ScheduleJobCard({ job }: { job: JobRow }) {
|
||||
const ts = jobTimestamp(job)
|
||||
const timeLabel = ts
|
||||
? format(parseISO(ts), 'd MMM · HH:mm', { locale: ru })
|
||||
: '—'
|
||||
|
||||
return (
|
||||
<Item variant="outline" size="xs" className="flex items-start gap-3 py-3">
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
|
||||
<p className="text-foreground text-sm leading-tight font-medium">{jobKindRu(job.kind)}</p>
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<StatusBadge status={job.status} />
|
||||
<span className="text-muted-foreground flex items-center gap-1 text-xs">
|
||||
<Clock className="size-3 shrink-0" aria-hidden />
|
||||
{timeLabel}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-muted-foreground font-mono text-xs">{job.job_id}</p>
|
||||
</div>
|
||||
</Item>
|
||||
)
|
||||
}
|
||||
|
||||
/** schedule-1 layout: calendar column + day job list. */
|
||||
export function ScheduleAgendaPanel({
|
||||
jobs,
|
||||
isLoading,
|
||||
@@ -18,25 +72,12 @@ export function ScheduleAgendaPanel({
|
||||
isLoading?: boolean
|
||||
}) {
|
||||
const [date, setDate] = useState<Date>(new Date())
|
||||
|
||||
const dayJobs = useMemo(
|
||||
() =>
|
||||
jobs.filter((job) => {
|
||||
const raw = job.created_at ?? job.started_at ?? job.finished_at
|
||||
if (!raw) return false
|
||||
try {
|
||||
return isSameDay(parseISO(raw), date)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}),
|
||||
[jobs, date],
|
||||
)
|
||||
const [filter, setFilter] = useState<JobFilter>('all')
|
||||
|
||||
const markedDays = useMemo(() => {
|
||||
const days = new Set<string>()
|
||||
for (const job of jobs) {
|
||||
const raw = job.created_at ?? job.started_at ?? job.finished_at
|
||||
const raw = jobTimestamp(job)
|
||||
if (!raw) continue
|
||||
try {
|
||||
days.add(format(parseISO(raw), 'yyyy-MM-dd'))
|
||||
@@ -47,44 +88,85 @@ export function ScheduleAgendaPanel({
|
||||
return days
|
||||
}, [jobs])
|
||||
|
||||
const dayJobs = useMemo(
|
||||
() =>
|
||||
jobs.filter((job) => {
|
||||
const raw = jobTimestamp(job)
|
||||
if (!raw) return false
|
||||
try {
|
||||
return isSameDay(parseISO(raw), date) && matchesFilter(job, filter)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}),
|
||||
[jobs, date, filter],
|
||||
)
|
||||
|
||||
const headingLabel = format(date, 'EEEE, d MMMM', { locale: ru })
|
||||
|
||||
return (
|
||||
<PanelCard
|
||||
title="Календарь задач"
|
||||
description="Задачи refresh и apply по дням (schedule-1 pattern)"
|
||||
className="h-full"
|
||||
description="Задачи refresh и apply по дням"
|
||||
contentClassName={cn(panelCardContentFlushClassName, 'p-0')}
|
||||
>
|
||||
<div className="grid gap-4 p-4 lg:grid-cols-[minmax(0,280px)_1fr]">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={date}
|
||||
onSelect={(d) => d && setDate(d)}
|
||||
locale={ru}
|
||||
modifiers={{
|
||||
hasJob: (d) => markedDays.has(format(d, 'yyyy-MM-dd')),
|
||||
}}
|
||||
modifiersClassNames={{ hasJob: 'font-bold underline' }}
|
||||
/>
|
||||
<ScrollArea className="h-64 lg:h-auto">
|
||||
{isLoading ? (
|
||||
<p className="text-muted-foreground text-sm">Загрузка…</p>
|
||||
) : dayJobs.length === 0 ? (
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Нет задач за {format(date, 'd MMMM yyyy', { locale: ru })}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2 pr-3">
|
||||
{dayJobs.map((job) => (
|
||||
<li key={job.job_id} className="rounded-lg border px-3 py-2 text-sm">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="font-medium">{jobKindRu(job.kind)}</span>
|
||||
<StatusBadge status={job.status} />
|
||||
</div>
|
||||
<p className="text-muted-foreground mt-1 text-xs">{job.job_id}</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</ScrollArea>
|
||||
<div className="flex flex-col lg:flex-row">
|
||||
<div className="border-border shrink-0 border-b p-5 lg:w-[370px] lg:border-r lg:border-b-0">
|
||||
<ScheduleCalendarView
|
||||
selected={date}
|
||||
onSelect={(d) => d && setDate(d)}
|
||||
datesWithEvents={markedDays}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-4 p-5">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="text-foreground text-sm font-semibold capitalize">{headingLabel}</h2>
|
||||
<p className="text-muted-foreground text-xs">
|
||||
{isLoading
|
||||
? 'Загрузка…'
|
||||
: dayJobs.length > 0
|
||||
? `${dayJobs.length} ${dayJobs.length === 1 ? 'задача' : dayJobs.length < 5 ? 'задачи' : 'задач'}`
|
||||
: 'Нет задач за выбранный день'}
|
||||
</p>
|
||||
</div>
|
||||
<Select value={filter} onValueChange={(v) => v && setFilter(v as JobFilter)}>
|
||||
<SelectTrigger size="sm" className="w-full sm:w-44">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{FILTER_ITEMS.map((item) => (
|
||||
<SelectItem key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="min-h-[280px]">
|
||||
{isLoading ? (
|
||||
<p className="text-muted-foreground text-sm">Загрузка задач…</p>
|
||||
) : dayJobs.length === 0 ? (
|
||||
<IllustratedEmptyState
|
||||
icon={CalendarDays}
|
||||
title="Нет задач"
|
||||
description={`За ${format(date, 'd MMMM yyyy', { locale: ru })} задачи не найдены. Выберите другой день или измените фильтр.`}
|
||||
/>
|
||||
) : (
|
||||
<ScrollArea className="max-h-[320px] pr-3">
|
||||
<ul className="space-y-2.5">
|
||||
{dayJobs.map((job) => (
|
||||
<li key={job.job_id}>
|
||||
<ScheduleJobCard job={job} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PanelCard>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useState, type ComponentPropsWithoutRef } from 'react'
|
||||
import { DayButton } from 'react-day-picker'
|
||||
import { format } from 'date-fns'
|
||||
import { ru } from 'date-fns/locale'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
|
||||
import { cn } from '@evobgp/ui/lib/utils'
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { Calendar, CalendarDayButton } from '@evobgp/ui/components/calendar'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@evobgp/ui/components/select'
|
||||
|
||||
const MONTHS_RU = Array.from({ length: 12 }, (_, i) =>
|
||||
format(new Date(2024, i, 1), 'LLLL', { locale: ru }),
|
||||
)
|
||||
|
||||
const CURRENT_YEAR = new Date().getFullYear()
|
||||
const YEARS = Array.from({ length: 11 }, (_, i) => CURRENT_YEAR - 5 + i)
|
||||
|
||||
const TODAY_WEEKDAY = format(new Date(), 'EEEEEE', { locale: ru }).toUpperCase()
|
||||
|
||||
export function ScheduleCalendarView({
|
||||
selected,
|
||||
onSelect,
|
||||
datesWithEvents = new Set<string>(),
|
||||
}: {
|
||||
selected: Date | undefined
|
||||
onSelect: (date: Date | undefined) => void
|
||||
datesWithEvents?: Set<string>
|
||||
}) {
|
||||
const [month, setMonth] = useState<Date>(selected ?? new Date())
|
||||
|
||||
const stepMonth = (delta: number) =>
|
||||
setMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() + delta, 1))
|
||||
|
||||
const handleMonthSelect = (value: string) => {
|
||||
const i = MONTHS_RU.indexOf(value)
|
||||
if (i >= 0) setMonth(new Date(month.getFullYear(), i, 1))
|
||||
}
|
||||
|
||||
const handleYearSelect = (value: string) => {
|
||||
const y = parseInt(value, 10)
|
||||
if (!Number.isNaN(y)) setMonth(new Date(y, month.getMonth(), 1))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 select-none">
|
||||
<div className="flex w-full grow items-center justify-between gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-7 shrink-0 p-0"
|
||||
onClick={() => stepMonth(-1)}
|
||||
aria-label="Предыдущий месяц"
|
||||
>
|
||||
<ChevronLeft className="size-3.5" aria-hidden />
|
||||
</Button>
|
||||
|
||||
<Select
|
||||
value={MONTHS_RU[month.getMonth()]}
|
||||
onValueChange={(value) => value && handleMonthSelect(value)}
|
||||
>
|
||||
<SelectTrigger size="sm" className="min-w-0 flex-1 capitalize">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MONTHS_RU.map((m) => (
|
||||
<SelectItem key={m} value={m} className="capitalize">
|
||||
{m}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={String(month.getFullYear())}
|
||||
onValueChange={(value) => value && handleYearSelect(value)}
|
||||
>
|
||||
<SelectTrigger size="sm" className="w-22 shrink-0">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{YEARS.map((y) => (
|
||||
<SelectItem key={y} value={String(y)}>
|
||||
{y}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="size-7 shrink-0 p-0"
|
||||
onClick={() => stepMonth(1)}
|
||||
aria-label="Следующий месяц"
|
||||
>
|
||||
<ChevronRight className="size-3.5" aria-hidden />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selected}
|
||||
onSelect={onSelect}
|
||||
month={month}
|
||||
onMonthChange={setMonth}
|
||||
locale={ru}
|
||||
showOutsideDays
|
||||
hideNavigation
|
||||
className="w-full bg-transparent p-0 md:[--cell-size:--spacing(11)]"
|
||||
formatters={{
|
||||
formatWeekdayName: (date) =>
|
||||
date.toLocaleString('ru-RU', { weekday: 'short' }).replace('.', '').toUpperCase(),
|
||||
}}
|
||||
classNames={{
|
||||
month_caption: 'hidden',
|
||||
nav: 'hidden',
|
||||
weekdays: 'flex gap-1',
|
||||
weekday:
|
||||
'flex-1 flex items-center justify-center h-6 text-[0.65rem] font-medium text-muted-foreground',
|
||||
week: 'flex gap-1 mt-1',
|
||||
day: 'flex-1 aspect-square p-0',
|
||||
day_button: cn(
|
||||
'bg-muted/50 hover:bg-muted rounded-md',
|
||||
'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[selected-single=true]:hover:bg-primary data-[selected-single=true]:hover:text-primary-foreground!',
|
||||
),
|
||||
outside: 'opacity-60',
|
||||
disabled: 'opacity-60',
|
||||
today: cn('bg-accent text-foreground rounded-md'),
|
||||
}}
|
||||
components={{
|
||||
Weekday: ({ children, className: cls, ...props }: ComponentPropsWithoutRef<'th'>) => {
|
||||
const isToday = children === TODAY_WEEKDAY
|
||||
return (
|
||||
<th
|
||||
scope="col"
|
||||
className={cn(
|
||||
'flex h-6! flex-1 items-center justify-center rounded-md text-xs font-medium',
|
||||
isToday ? 'bg-accent text-foreground!' : 'text-muted-foreground',
|
||||
cls,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</th>
|
||||
)
|
||||
},
|
||||
DayButton: ({
|
||||
children,
|
||||
modifiers,
|
||||
day,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DayButton>) => {
|
||||
const dateKey = format(day.date, 'yyyy-MM-dd')
|
||||
const hasEvents = !modifiers.outside && datesWithEvents.has(dateKey)
|
||||
|
||||
return (
|
||||
<CalendarDayButton day={day} modifiers={modifiers} {...props}>
|
||||
{hasEvents ? (
|
||||
<span
|
||||
className="bg-primary text-primary-foreground in-data-[selected-single=true]:bg-primary-foreground! size-1 rounded-full"
|
||||
aria-hidden
|
||||
/>
|
||||
) : (
|
||||
<span className="size-1" aria-hidden />
|
||||
)}
|
||||
{children}
|
||||
</CalendarDayButton>
|
||||
)
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { useMemo, useState } from 'react'
|
||||
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
import { TableSkeleton } from '@/components/skeletons'
|
||||
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||||
import type { JobRow } from '@/types/api'
|
||||
|
||||
import { ScheduleJobsGrid } from './schedule-jobs-grid'
|
||||
|
||||
type JobTab = 'all' | 'refresh' | 'failed'
|
||||
|
||||
function filterJobs(items: JobRow[], tab: JobTab): JobRow[] {
|
||||
if (tab === 'refresh') return items.filter((j) => j.kind === 'module_refresh')
|
||||
if (tab === 'failed')
|
||||
return items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
||||
return items
|
||||
}
|
||||
|
||||
function tabCounts(items: JobRow[]) {
|
||||
return {
|
||||
all: items.length,
|
||||
refresh: items.filter((j) => j.kind === 'module_refresh').length,
|
||||
failed: items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
|
||||
.length,
|
||||
}
|
||||
}
|
||||
|
||||
/** Jobs data-grid with status tabs (data-grid-filtering pattern). */
|
||||
export function ScheduleJobsCard({
|
||||
jobs,
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
onRetry,
|
||||
}: {
|
||||
jobs: JobRow[]
|
||||
isLoading: boolean
|
||||
isError: boolean
|
||||
error: unknown
|
||||
onRetry: () => void
|
||||
}) {
|
||||
const [tab, setTab] = useState<JobTab>('all')
|
||||
const counts = useMemo(() => tabCounts(jobs), [jobs])
|
||||
const filtered = useMemo(() => filterJobs(jobs, tab), [jobs, tab])
|
||||
|
||||
return (
|
||||
<DataGridCard title="Задачи" description="Последние задачи из API">
|
||||
<div className="border-b px-5 py-3">
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as JobTab)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="all">Все ({counts.all})</TabsTrigger>
|
||||
<TabsTrigger value="refresh">Обновление ({counts.refresh})</TabsTrigger>
|
||||
<TabsTrigger value="failed">С ошибкой ({counts.failed})</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
</div>
|
||||
<QueryState
|
||||
data={filtered}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
error={error}
|
||||
empty={filtered.length === 0}
|
||||
emptyTitle="Нет задач в выборке"
|
||||
skeleton={<TableSkeleton rows={6} cols={5} />}
|
||||
onRetry={onRetry}
|
||||
>
|
||||
{(items) => (
|
||||
<ScheduleJobsGrid
|
||||
items={items}
|
||||
isLoading={isLoading && items.length > 0}
|
||||
/>
|
||||
)}
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
)
|
||||
}
|
||||
@@ -90,6 +90,7 @@ export function ScheduleJobsGrid({
|
||||
recordCount={filteredCount}
|
||||
isLoading={isLoading}
|
||||
emptyMessage="Нет задач"
|
||||
showPagination={items.length > 10}
|
||||
searchValue={globalFilter}
|
||||
onSearchChange={setGlobalFilter}
|
||||
searchPlaceholder="Поиск задач…"
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -2,13 +2,12 @@ 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 { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { Button } from '@evobgp/ui/components/button'
|
||||
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
|
||||
import { DataGridCard } from '@/components/data-grid-shell'
|
||||
import { ScheduleAgendaPanel } from '@/components/schedule/schedule-agenda-panel'
|
||||
import { ScheduleJobsGrid } from '@/components/schedule/schedule-jobs-grid'
|
||||
import { ScheduleJobsCard } from '@/components/schedule/schedule-jobs-card'
|
||||
import { ScheduleModulesGrid } from '@/components/schedule/schedule-modules-grid'
|
||||
import { PageHeader } from '@/components/page-header'
|
||||
import { QueryState } from '@/components/query-state'
|
||||
@@ -18,7 +17,6 @@ import { SectionCardsSkeleton } from '@/components/skeletons'
|
||||
import { operationsJobsQueryOptions } from '@/queries/operations'
|
||||
import { modulesListQueryOptions } from '@/queries/modules'
|
||||
import { apiMutate } from '@/lib/api-client'
|
||||
import type { JobRow } from '@/types/api'
|
||||
|
||||
export const Route = createFileRoute('/_auth/schedule')({
|
||||
component: ScheduleComponent,
|
||||
@@ -38,6 +36,30 @@ 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: <ListTodo className="size-4" />, hint: 'в выборке' },
|
||||
{ label: 'В работе', value: running, icon: <Clock className="size-4" />, hint: 'в очереди и выполняются' },
|
||||
@@ -115,43 +137,13 @@ function ScheduleComponent() {
|
||||
</QueryState>
|
||||
</DataGridCard>
|
||||
|
||||
<DataGridCard title="Задачи" description="Последние задачи из API">
|
||||
<JobsTabs jobs={jobs} loading={jobsQ.isLoading} />
|
||||
</DataGridCard>
|
||||
<ScheduleJobsCard
|
||||
jobs={jobs}
|
||||
isLoading={jobsQ.isLoading}
|
||||
isError={jobsQ.isError}
|
||||
error={jobsQ.error}
|
||||
onRetry={() => jobsQ.refetch()}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function JobsTabs({ jobs, loading }: { jobs: JobRow[]; loading: boolean }) {
|
||||
const refresh = jobs.filter((j) => j.kind === 'module_refresh')
|
||||
const failed = jobs.filter((j) =>
|
||||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||||
)
|
||||
|
||||
return (
|
||||
<BadgeTabs
|
||||
defaultValue="all"
|
||||
listClassName="mx-3 mb-0 w-auto"
|
||||
items={[
|
||||
{ value: 'all', label: 'Все', count: jobs.length },
|
||||
{ value: 'refresh', label: 'Обновление', count: refresh.length, badgeVariant: 'info-light' },
|
||||
{
|
||||
value: 'failed',
|
||||
label: 'С ошибкой',
|
||||
count: failed.length,
|
||||
badgeVariant: failed.length > 0 ? 'destructive-light' : 'primary-light',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<TabsContent value="all" className="mt-0">
|
||||
<ScheduleJobsGrid items={jobs} isLoading={loading} />
|
||||
</TabsContent>
|
||||
<TabsContent value="refresh" className="mt-0">
|
||||
<ScheduleJobsGrid items={refresh} isLoading={loading} />
|
||||
</TabsContent>
|
||||
<TabsContent value="failed" className="mt-0">
|
||||
<ScheduleJobsGrid items={failed} isLoading={loading} />
|
||||
</TabsContent>
|
||||
</BadgeTabs>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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