Compare commits

...
2 Commits
Author SHA1 Message Date
Denozordec bbcf9d76d9 refactor: enhance schedule components with improved filtering and layout
CI / changes (push) Successful in 11s
CI / openapi (push) Has been skipped
CI / commitlint (push) Has been skipped
CI / web (push) Successful in 51s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m20s
Updated the ScheduleAgendaPanel to include a job filter feature, allowing users to filter jobs by type (all, refresh, failed). Refactored the layout to integrate a new ScheduleCalendarView for better organization. Enhanced the ScheduleJobsGrid to conditionally display pagination based on the number of items. Additionally, modified the Schedule component to utilize the new ScheduleJobsCard for improved job display and loading states, ensuring a more cohesive user experience.
2026-07-09 23:10:20 +07:00
Denozordec 55eb2a6c89 refactor: enhance dashboard layout and settings components for improved user experience
CI / changes (push) Successful in 11s
CI / commitlint (push) Has been skipped
CI / openapi (push) Has been skipped
CI / web (push) Successful in 1m7s
CI / go (push) Has been skipped
CI / bird2 (push) Has been skipped
CI / release (push) Successful in 4m20s
Updated the AnalyticsDashboardSkeleton to utilize a new grid layout for KPI tiles, increasing the number of displayed items and adjusting their size for better visibility. Modified the SettingsSettingField component to accept a ReactNode for the description, allowing for richer content. Refactored the Settings component to improve the organization of API token management and session status display, enhancing clarity and usability. Additionally, integrated new UI elements for theme selection and improved layout consistency across components.
2026-07-09 21:58:39 +07:00
12 changed files with 749 additions and 158 deletions
@@ -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 { useMemo, useState } from 'react'
import { format, isSameDay, parseISO } from 'date-fns' import { format, isSameDay, parseISO } from 'date-fns'
import { ru } from 'date-fns/locale' import { ru } from 'date-fns/locale'
import { CalendarDays, Clock } from 'lucide-react'
import { PanelCard } from '@/components/panel-card' import { IllustratedEmptyState } from '@/components/patterns/illustrated-empty-state'
import { PanelCard, panelCardContentFlushClassName } from '@/components/panel-card'
import { StatusBadge } from '@/components/status-badge' import { StatusBadge } from '@/components/status-badge'
import { Calendar } from '@evobgp/ui/components/calendar' import { Item } from '@evobgp/ui/components/item'
import { ScrollArea } from '@evobgp/ui/components/scroll-area' import { ScrollArea } from '@evobgp/ui/components/scroll-area'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
import { cn } from '@evobgp/ui/lib/utils'
import { jobKindRu } from '@/lib/ui-labels' import { jobKindRu } from '@/lib/ui-labels'
import type { JobRow } from '@/types/api' import type { JobRow } from '@/types/api'
/** schedule-1 inspired agenda: calendar + day job list. */ import { ScheduleCalendarView } from './schedule-calendar-view'
type JobFilter = 'all' | 'refresh' | 'failed'
const FILTER_ITEMS: { value: JobFilter; label: string }[] = [
{ value: 'all', label: 'Все задачи' },
{ value: 'refresh', label: 'Обновление' },
{ value: 'failed', label: 'С ошибкой' },
]
function jobTimestamp(job: JobRow): string | undefined {
return job.created_at ?? job.started_at ?? job.finished_at ?? undefined
}
function matchesFilter(job: JobRow, filter: JobFilter): boolean {
if (filter === 'refresh') return job.kind === 'module_refresh'
if (filter === 'failed')
return ['failed', 'error', 'cancelled'].includes(job.status.toLowerCase())
return true
}
function ScheduleJobCard({ job }: { job: JobRow }) {
const ts = jobTimestamp(job)
const timeLabel = ts
? format(parseISO(ts), 'd MMM · HH:mm', { locale: ru })
: '—'
return (
<Item variant="outline" size="xs" className="flex items-start gap-3 py-3">
<div className="flex min-w-0 flex-1 flex-col gap-1.5">
<p className="text-foreground text-sm leading-tight font-medium">{jobKindRu(job.kind)}</p>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
<StatusBadge status={job.status} />
<span className="text-muted-foreground flex items-center gap-1 text-xs">
<Clock className="size-3 shrink-0" aria-hidden />
{timeLabel}
</span>
</div>
<p className="text-muted-foreground font-mono text-xs">{job.job_id}</p>
</div>
</Item>
)
}
/** schedule-1 layout: calendar column + day job list. */
export function ScheduleAgendaPanel({ export function ScheduleAgendaPanel({
jobs, jobs,
isLoading, isLoading,
@@ -18,25 +72,12 @@ export function ScheduleAgendaPanel({
isLoading?: boolean isLoading?: boolean
}) { }) {
const [date, setDate] = useState<Date>(new Date()) const [date, setDate] = useState<Date>(new Date())
const [filter, setFilter] = useState<JobFilter>('all')
const dayJobs = useMemo(
() =>
jobs.filter((job) => {
const raw = job.created_at ?? job.started_at ?? job.finished_at
if (!raw) return false
try {
return isSameDay(parseISO(raw), date)
} catch {
return false
}
}),
[jobs, date],
)
const markedDays = useMemo(() => { const markedDays = useMemo(() => {
const days = new Set<string>() const days = new Set<string>()
for (const job of jobs) { for (const job of jobs) {
const raw = job.created_at ?? job.started_at ?? job.finished_at const raw = jobTimestamp(job)
if (!raw) continue if (!raw) continue
try { try {
days.add(format(parseISO(raw), 'yyyy-MM-dd')) days.add(format(parseISO(raw), 'yyyy-MM-dd'))
@@ -47,44 +88,85 @@ export function ScheduleAgendaPanel({
return days return days
}, [jobs]) }, [jobs])
const dayJobs = useMemo(
() =>
jobs.filter((job) => {
const raw = jobTimestamp(job)
if (!raw) return false
try {
return isSameDay(parseISO(raw), date) && matchesFilter(job, filter)
} catch {
return false
}
}),
[jobs, date, filter],
)
const headingLabel = format(date, 'EEEE, d MMMM', { locale: ru })
return ( return (
<PanelCard <PanelCard
title="Календарь задач" title="Календарь задач"
description="Задачи refresh и apply по дням (schedule-1 pattern)" description="Задачи refresh и apply по дням"
className="h-full" contentClassName={cn(panelCardContentFlushClassName, 'p-0')}
> >
<div className="grid gap-4 p-4 lg:grid-cols-[minmax(0,280px)_1fr]"> <div className="flex flex-col lg:flex-row">
<Calendar <div className="border-border shrink-0 border-b p-5 lg:w-[370px] lg:border-r lg:border-b-0">
mode="single" <ScheduleCalendarView
selected={date} selected={date}
onSelect={(d) => d && setDate(d)} onSelect={(d) => d && setDate(d)}
locale={ru} datesWithEvents={markedDays}
modifiers={{ />
hasJob: (d) => markedDays.has(format(d, 'yyyy-MM-dd')), </div>
}}
modifiersClassNames={{ hasJob: 'font-bold underline' }} <div className="flex min-w-0 flex-1 flex-col gap-4 p-5">
/> <div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between sm:gap-3">
<ScrollArea className="h-64 lg:h-auto"> <div className="min-w-0">
{isLoading ? ( <h2 className="text-foreground text-sm font-semibold capitalize">{headingLabel}</h2>
<p className="text-muted-foreground text-sm">Загрузка</p> <p className="text-muted-foreground text-xs">
) : dayJobs.length === 0 ? ( {isLoading
<p className="text-muted-foreground text-sm"> ? 'Загрузка…'
Нет задач за {format(date, 'd MMMM yyyy', { locale: ru })} : dayJobs.length > 0
</p> ? `${dayJobs.length} ${dayJobs.length === 1 ? 'задача' : dayJobs.length < 5 ? 'задачи' : 'задач'}`
) : ( : 'Нет задач за выбранный день'}
<ul className="space-y-2 pr-3"> </p>
{dayJobs.map((job) => ( </div>
<li key={job.job_id} className="rounded-lg border px-3 py-2 text-sm"> <Select value={filter} onValueChange={(v) => v && setFilter(v as JobFilter)}>
<div className="flex items-center justify-between gap-2"> <SelectTrigger size="sm" className="w-full sm:w-44">
<span className="font-medium">{jobKindRu(job.kind)}</span> <SelectValue />
<StatusBadge status={job.status} /> </SelectTrigger>
</div> <SelectContent>
<p className="text-muted-foreground mt-1 text-xs">{job.job_id}</p> {FILTER_ITEMS.map((item) => (
</li> <SelectItem key={item.value} value={item.value}>
))} {item.label}
</ul> </SelectItem>
)} ))}
</ScrollArea> </SelectContent>
</Select>
</div>
<div className="min-h-[280px]">
{isLoading ? (
<p className="text-muted-foreground text-sm">Загрузка задач</p>
) : dayJobs.length === 0 ? (
<IllustratedEmptyState
icon={CalendarDays}
title="Нет задач"
description={`За ${format(date, 'd MMMM yyyy', { locale: ru })} задачи не найдены. Выберите другой день или измените фильтр.`}
/>
) : (
<ScrollArea className="max-h-[320px] pr-3">
<ul className="space-y-2.5">
{dayJobs.map((job) => (
<li key={job.job_id}>
<ScheduleJobCard job={job} />
</li>
))}
</ul>
</ScrollArea>
)}
</div>
</div>
</div> </div>
</PanelCard> </PanelCard>
) )
@@ -0,0 +1,181 @@
import { useState, type ComponentPropsWithoutRef } from 'react'
import { DayButton } from 'react-day-picker'
import { format } from 'date-fns'
import { ru } from 'date-fns/locale'
import { ChevronLeft, ChevronRight } from 'lucide-react'
import { cn } from '@evobgp/ui/lib/utils'
import { Button } from '@evobgp/ui/components/button'
import { Calendar, CalendarDayButton } from '@evobgp/ui/components/calendar'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@evobgp/ui/components/select'
const MONTHS_RU = Array.from({ length: 12 }, (_, i) =>
format(new Date(2024, i, 1), 'LLLL', { locale: ru }),
)
const CURRENT_YEAR = new Date().getFullYear()
const YEARS = Array.from({ length: 11 }, (_, i) => CURRENT_YEAR - 5 + i)
const TODAY_WEEKDAY = format(new Date(), 'EEEEEE', { locale: ru }).toUpperCase()
export function ScheduleCalendarView({
selected,
onSelect,
datesWithEvents = new Set<string>(),
}: {
selected: Date | undefined
onSelect: (date: Date | undefined) => void
datesWithEvents?: Set<string>
}) {
const [month, setMonth] = useState<Date>(selected ?? new Date())
const stepMonth = (delta: number) =>
setMonth((prev) => new Date(prev.getFullYear(), prev.getMonth() + delta, 1))
const handleMonthSelect = (value: string) => {
const i = MONTHS_RU.indexOf(value)
if (i >= 0) setMonth(new Date(month.getFullYear(), i, 1))
}
const handleYearSelect = (value: string) => {
const y = parseInt(value, 10)
if (!Number.isNaN(y)) setMonth(new Date(y, month.getMonth(), 1))
}
return (
<div className="flex flex-col items-center justify-center gap-4 select-none">
<div className="flex w-full grow items-center justify-between gap-1">
<Button
variant="ghost"
size="sm"
className="size-7 shrink-0 p-0"
onClick={() => stepMonth(-1)}
aria-label="Предыдущий месяц"
>
<ChevronLeft className="size-3.5" aria-hidden />
</Button>
<Select
value={MONTHS_RU[month.getMonth()]}
onValueChange={(value) => value && handleMonthSelect(value)}
>
<SelectTrigger size="sm" className="min-w-0 flex-1 capitalize">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MONTHS_RU.map((m) => (
<SelectItem key={m} value={m} className="capitalize">
{m}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
value={String(month.getFullYear())}
onValueChange={(value) => value && handleYearSelect(value)}
>
<SelectTrigger size="sm" className="w-22 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
{YEARS.map((y) => (
<SelectItem key={y} value={String(y)}>
{y}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
variant="ghost"
size="sm"
className="size-7 shrink-0 p-0"
onClick={() => stepMonth(1)}
aria-label="Следующий месяц"
>
<ChevronRight className="size-3.5" aria-hidden />
</Button>
</div>
<Calendar
mode="single"
selected={selected}
onSelect={onSelect}
month={month}
onMonthChange={setMonth}
locale={ru}
showOutsideDays
hideNavigation
className="w-full bg-transparent p-0 md:[--cell-size:--spacing(11)]"
formatters={{
formatWeekdayName: (date) =>
date.toLocaleString('ru-RU', { weekday: 'short' }).replace('.', '').toUpperCase(),
}}
classNames={{
month_caption: 'hidden',
nav: 'hidden',
weekdays: 'flex gap-1',
weekday:
'flex-1 flex items-center justify-center h-6 text-[0.65rem] font-medium text-muted-foreground',
week: 'flex gap-1 mt-1',
day: 'flex-1 aspect-square p-0',
day_button: cn(
'bg-muted/50 hover:bg-muted rounded-md',
'data-[selected-single=true]:bg-primary data-[selected-single=true]:text-primary-foreground data-[selected-single=true]:hover:bg-primary data-[selected-single=true]:hover:text-primary-foreground!',
),
outside: 'opacity-60',
disabled: 'opacity-60',
today: cn('bg-accent text-foreground rounded-md'),
}}
components={{
Weekday: ({ children, className: cls, ...props }: ComponentPropsWithoutRef<'th'>) => {
const isToday = children === TODAY_WEEKDAY
return (
<th
scope="col"
className={cn(
'flex h-6! flex-1 items-center justify-center rounded-md text-xs font-medium',
isToday ? 'bg-accent text-foreground!' : 'text-muted-foreground',
cls,
)}
{...props}
>
{children}
</th>
)
},
DayButton: ({
children,
modifiers,
day,
...props
}: React.ComponentProps<typeof DayButton>) => {
const dateKey = format(day.date, 'yyyy-MM-dd')
const hasEvents = !modifiers.outside && datesWithEvents.has(dateKey)
return (
<CalendarDayButton day={day} modifiers={modifiers} {...props}>
{hasEvents ? (
<span
className="bg-primary text-primary-foreground in-data-[selected-single=true]:bg-primary-foreground! size-1 rounded-full"
aria-hidden
/>
) : (
<span className="size-1" aria-hidden />
)}
{children}
</CalendarDayButton>
)
},
}}
/>
</div>
)
}
@@ -0,0 +1,77 @@
import { useMemo, useState } from 'react'
import { DataGridCard } from '@/components/data-grid-shell'
import { QueryState } from '@/components/query-state'
import { TableSkeleton } from '@/components/skeletons'
import { Tabs, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
import type { JobRow } from '@/types/api'
import { ScheduleJobsGrid } from './schedule-jobs-grid'
type JobTab = 'all' | 'refresh' | 'failed'
function filterJobs(items: JobRow[], tab: JobTab): JobRow[] {
if (tab === 'refresh') return items.filter((j) => j.kind === 'module_refresh')
if (tab === 'failed')
return items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
return items
}
function tabCounts(items: JobRow[]) {
return {
all: items.length,
refresh: items.filter((j) => j.kind === 'module_refresh').length,
failed: items.filter((j) => ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()))
.length,
}
}
/** Jobs data-grid with status tabs (data-grid-filtering pattern). */
export function ScheduleJobsCard({
jobs,
isLoading,
isError,
error,
onRetry,
}: {
jobs: JobRow[]
isLoading: boolean
isError: boolean
error: unknown
onRetry: () => void
}) {
const [tab, setTab] = useState<JobTab>('all')
const counts = useMemo(() => tabCounts(jobs), [jobs])
const filtered = useMemo(() => filterJobs(jobs, tab), [jobs, tab])
return (
<DataGridCard title="Задачи" description="Последние задачи из API">
<div className="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} recordCount={filteredCount}
isLoading={isLoading} isLoading={isLoading}
emptyMessage="Нет задач" emptyMessage="Нет задач"
showPagination={items.length > 10}
searchValue={globalFilter} searchValue={globalFilter}
onSearchChange={setGlobalFilter} onSearchChange={setGlobalFilter}
searchPlaceholder="Поиск задач…" searchPlaceholder="Поиск задач…"
@@ -22,7 +22,7 @@ export function SettingsSettingField({
contentClassName, contentClassName,
}: { }: {
title: string title: string
description: string description: ReactNode
badge?: { label: string; variant: ComponentProps<typeof Badge>['variant'] } badge?: { label: string; variant: ComponentProps<typeof Badge>['variant'] }
children: ReactNode children: ReactNode
last?: boolean last?: boolean
+4 -3
View File
@@ -3,6 +3,7 @@ import { Skeleton } from '@evobgp/ui/components/skeleton'
import { panelCardInsetClassName } from '@/components/panel-card' import { panelCardInsetClassName } from '@/components/panel-card'
import { import {
chartPanelGridClassName, chartPanelGridClassName,
dashboardKpiGridClassName,
dashboardMainSidebarClassName, dashboardMainSidebarClassName,
kpiGridClassName, kpiGridClassName,
} from '@/lib/ui-surface' } from '@/lib/ui-surface'
@@ -24,9 +25,9 @@ export function SectionCardsSkeleton({ count = 4 }: { count?: number }) {
export function AnalyticsDashboardSkeleton() { export function AnalyticsDashboardSkeleton() {
return ( return (
<div className="@container flex flex-col gap-4"> <div className="@container flex flex-col gap-4">
<div className={kpiGridClassName}> <div className={dashboardKpiGridClassName}>
{Array.from({ length: 4 }).map((_, i) => ( {Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={`kpi-${i}`} className="h-36 w-full rounded-xl" /> <Skeleton key={`kpi-${i}`} className="h-28 w-full rounded-xl" />
))} ))}
</div> </div>
+4
View File
@@ -15,6 +15,10 @@ export const UI_SURFACE = 'card' as const
/** Shared padding for KPI / metric tiles inside PanelCard. */ /** Shared padding for KPI / metric tiles inside PanelCard. */
export const kpiCardContentClassName = 'flex flex-col items-start gap-4 p-5' 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). */ /** Grid for dashboard-5 style KPI sparkline row (4 columns at xl). */
export const kpiGridClassName = export const kpiGridClassName =
'grid grid-cols-1 gap-4 @3xl:grid-cols-2 @6xl:grid-cols-4' 'grid grid-cols-1 gap-4 @3xl:grid-cols-2 @6xl:grid-cols-4'
+2 -2
View File
@@ -8,7 +8,7 @@ import { Skeleton } from '@evobgp/ui/components/skeleton'
import { DashboardActivityTimeline } from '@/components/dashboard/dashboard-activity-timeline' import { DashboardActivityTimeline } from '@/components/dashboard/dashboard-activity-timeline'
import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel' import { DashboardFramePanel } from '@/components/dashboard/dashboard-frame-panel'
import { DashboardKpiSparklineRow } from '@/components/dashboard/dashboard-kpi-sparkline-row' import { DashboardKpiGrid } from '@/components/dashboard/dashboard-kpi-grid'
import { DashboardModulesGrid } from '@/components/dashboard/dashboard-modules-grid' import { DashboardModulesGrid } from '@/components/dashboard/dashboard-modules-grid'
import { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health' import { DashboardNetworkHealth } from '@/components/dashboard/dashboard-network-health'
import { DashboardOperationsBreakdown } from '@/components/dashboard/dashboard-operations-breakdown' import { DashboardOperationsBreakdown } from '@/components/dashboard/dashboard-operations-breakdown'
@@ -92,7 +92,7 @@ function DashboardComponent() {
<AnalyticsDashboardSkeleton /> <AnalyticsDashboardSkeleton />
) : ( ) : (
<> <>
<DashboardKpiSparklineRow <DashboardKpiGrid
modules={modules} modules={modules}
peers={peers} peers={peers}
speakers={speakers} speakers={speakers}
+33 -41
View File
@@ -2,13 +2,12 @@ import { createFileRoute } from '@tanstack/react-router'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { AlertTriangle, Clock, ListTodo, RefreshCw } from 'lucide-react' import { AlertTriangle, Clock, ListTodo, RefreshCw } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { useState } from 'react' import { useEffect, useState } from 'react'
import { Button } from '@evobgp/ui/components/button' import { Button } from '@evobgp/ui/components/button'
import { BadgeTabs, TabsContent } from '@/components/badge-tabs'
import { DataGridCard } from '@/components/data-grid-shell' import { DataGridCard } from '@/components/data-grid-shell'
import { ScheduleAgendaPanel } from '@/components/schedule/schedule-agenda-panel' import { ScheduleAgendaPanel } from '@/components/schedule/schedule-agenda-panel'
import { ScheduleJobsGrid } from '@/components/schedule/schedule-jobs-grid' import { ScheduleJobsCard } from '@/components/schedule/schedule-jobs-card'
import { ScheduleModulesGrid } from '@/components/schedule/schedule-modules-grid' import { ScheduleModulesGrid } from '@/components/schedule/schedule-modules-grid'
import { PageHeader } from '@/components/page-header' import { PageHeader } from '@/components/page-header'
import { QueryState } from '@/components/query-state' import { QueryState } from '@/components/query-state'
@@ -18,7 +17,6 @@ import { SectionCardsSkeleton } from '@/components/skeletons'
import { operationsJobsQueryOptions } from '@/queries/operations' import { operationsJobsQueryOptions } from '@/queries/operations'
import { modulesListQueryOptions } from '@/queries/modules' import { modulesListQueryOptions } from '@/queries/modules'
import { apiMutate } from '@/lib/api-client' import { apiMutate } from '@/lib/api-client'
import type { JobRow } from '@/types/api'
export const Route = createFileRoute('/_auth/schedule')({ export const Route = createFileRoute('/_auth/schedule')({
component: ScheduleComponent, component: ScheduleComponent,
@@ -38,6 +36,30 @@ function ScheduleComponent() {
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()), ['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
).length ).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[] = [ const items: SectionCardItem[] = [
{ label: 'Всего задач', value: jobs.length, icon: <ListTodo className="size-4" />, hint: 'в выборке' }, { label: 'Всего задач', value: jobs.length, icon: <ListTodo className="size-4" />, hint: 'в выборке' },
{ label: 'В работе', value: running, icon: <Clock className="size-4" />, hint: 'в очереди и выполняются' }, { label: 'В работе', value: running, icon: <Clock className="size-4" />, hint: 'в очереди и выполняются' },
@@ -115,43 +137,13 @@ function ScheduleComponent() {
</QueryState> </QueryState>
</DataGridCard> </DataGridCard>
<DataGridCard title="Задачи" description="Последние задачи из API"> <ScheduleJobsCard
<JobsTabs jobs={jobs} loading={jobsQ.isLoading} /> jobs={jobs}
</DataGridCard> isLoading={jobsQ.isLoading}
isError={jobsQ.isError}
error={jobsQ.error}
onRetry={() => jobsQ.refetch()}
/>
</div> </div>
) )
} }
function JobsTabs({ jobs, loading }: { jobs: JobRow[]; loading: boolean }) {
const refresh = jobs.filter((j) => j.kind === 'module_refresh')
const failed = jobs.filter((j) =>
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
)
return (
<BadgeTabs
defaultValue="all"
listClassName="mx-3 mb-0 w-auto"
items={[
{ value: 'all', label: 'Все', count: jobs.length },
{ value: 'refresh', label: 'Обновление', count: refresh.length, badgeVariant: 'info-light' },
{
value: 'failed',
label: 'С ошибкой',
count: failed.length,
badgeVariant: failed.length > 0 ? 'destructive-light' : 'primary-light',
},
]}
>
<TabsContent value="all" className="mt-0">
<ScheduleJobsGrid items={jobs} isLoading={loading} />
</TabsContent>
<TabsContent value="refresh" className="mt-0">
<ScheduleJobsGrid items={refresh} isLoading={loading} />
</TabsContent>
<TabsContent value="failed" className="mt-0">
<ScheduleJobsGrid items={failed} isLoading={loading} />
</TabsContent>
</BadgeTabs>
)
}
+128 -58
View File
@@ -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 { useQuery, useQueryClient } from '@tanstack/react-query'
import { Moon, Save, Sun, SunMoon } from 'lucide-react'
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 { useTheme } from 'next-themes' import { useTheme } from 'next-themes'
import { useEffect, useState } from 'react' 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')({ export const Route = createFileRoute('/_auth/settings')({
component: SettingsComponent, component: SettingsComponent,
}) })
const THEME_SELECT_ITEMS = [ const THEME_OPTIONS = [
{ value: 'light', label: 'Светлая' }, { value: 'light', label: 'Светлая', icon: Sun },
{ value: 'dark', label: 'Тёмная' }, { value: 'dark', label: 'Тёмная', icon: Moon },
{ value: 'system', label: 'Как в системе' }, { value: 'system', label: 'Система', icon: SunMoon },
] as const ] as const
function SettingsComponent() { function SettingsComponent() {
@@ -66,6 +70,12 @@ function SettingsComponent() {
void applyToken(DEV_API_TOKEN) void applyToken(DEV_API_TOKEN)
} }
const showSessionRow = Boolean(session || sessionError)
const sessionErrorMessage =
sessionQueryError instanceof Error
? sessionQueryError.message
: 'Не удалось проверить сессию'
return ( return (
<div className="mx-auto flex max-w-3xl flex-col gap-6"> <div className="mx-auto flex max-w-3xl flex-col gap-6">
<PageHeader <PageHeader
@@ -76,18 +86,45 @@ function SettingsComponent() {
{tokenRequired ? ( {tokenRequired ? (
<Alert> <Alert>
<AlertDescription> <AlertDescription>
Для доступа к разделу нужен API-токен. Сохраните токен ниже или нажмите «Использовать dev». Для доступа к разделу нужен API-токен. Сохраните токен ниже или нажмите «Использовать
dev».
</AlertDescription> </AlertDescription>
</Alert> </Alert>
) : null} ) : null}
<PanelCard <PanelCard
title="Подключение к API" title="Подключение к API"
description="Токен хранится только в этом браузере (localStorage). Управление ключами tenant — в разделе «Права доступа»." description="Токен хранится только в этом браузере (localStorage)."
contentClassName="flex flex-col gap-4 py-4" 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"> <FieldGroup className="gap-0">
<Label htmlFor="token">Токен для запросов</Label> <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 <Input
id="token" id="token"
type="password" type="password"
@@ -96,47 +133,80 @@ function SettingsComponent() {
onChange={(e) => setTokenValue(e.target.value)} onChange={(e) => setTokenValue(e.target.value)}
placeholder="dev или API-ключ" placeholder="dev или API-ключ"
/> />
</div> </SettingsSettingField>
<div className="flex flex-wrap gap-2">
<LoadingButton onClick={saveTokenHandler}> {showSessionRow ? (
<Save /> <SettingsSettingField
Сохранить токен title="Статус сессии"
</LoadingButton> description={
<Button type="button" variant="outline" onClick={useDevToken}> session
Использовать dev ? 'Проверка токена через GET /v1/auth/session.'
</Button> : 'Токен сохранён, но сессия не подтверждена API.'
</div> }
{session ? ( badge={
<p className="text-xs text-muted-foreground"> session
Активная сессия: tenant <code className="font-mono">{session.tenant_id}</code>, роль{' '} ? { label: session.role, variant: 'success-light' }
<code className="font-mono">{session.role}</code>. : { label: 'ошибка', variant: 'destructive-light' }
</p> }
) : null} last
{sessionError ? ( >
<p className="text-xs text-destructive"> {session ? (
{sessionQueryError instanceof Error <div className="text-muted-foreground space-y-1 text-sm">
? sessionQueryError.message <p>
: 'Не удалось проверить сессию'} Tenant:{' '}
. Для токена <code className="font-mono">dev</code> нужен demo-seed ( <code className="text-foreground font-mono text-xs">{session.tenant_id}</code>
<code className="text-xs">EVOBGP_SEED_DEMO</code> 0) и запущенный API. </p>
</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} ) : null}
</FieldGroup>
</PanelCard> </PanelCard>
<PanelCard <PanelCard
title="Оформление" title="Оформление"
description="Тема интерфейса. Быстрый переключатель также доступен в боковой панели." description="Тема интерфейса. Быстрый переключатель также доступен в боковой панели."
contentClassName="flex flex-col gap-2 py-4" contentClassName="p-0"
> >
<SelectField <FieldGroup className="gap-0">
id="theme-select" <SettingsSettingField
label="Тема" title="Тема"
items={[...THEME_SELECT_ITEMS]} description="Влияет на цветовую схему всех экранов в этом браузере."
value={theme ?? 'system'} badge={{ label: 'мгновенно', variant: 'primary-light' }}
placeholder="Выберите тему" last
triggerClassName="max-w-xs" >
onValueChange={(v) => v && setTheme(v)} <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> </PanelCard>
</div> </div>
) )
File diff suppressed because one or more lines are too long