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 { 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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user