CI / changes (push) Successful in 17s
CI / commitlint (push) Has been skipped
CI / openapi (push) Successful in 26s
CI / web (push) Successful in 46s
CI / go (push) Successful in 1m1s
CI / bird2 (push) Successful in 17s
CI / release (push) Failing after 2m22s
Web UI полностью переведён с SvelteKit на новый стек: React 19, TanStack Router/Query/Table/Virtual, shadcn/ui (base-nova) и ReUI enterprise-компоненты (data-grid, filters, autocomplete). Новый код разложен по слоям: packages/ui (shadcn-примитивы), apps/web (роуты, shared-обёртки, ReUI-адаптации). BREAKING CHANGE: меняется структура и инструментинг фронтенда. - apps/web/ — новый Vite + React-проект (@evobgp/web), file-based роуты TanStack Router; экраны dashboard, modules, monitoring, network, operations, schedule, settings, tenant-settings, access, directories. - packages/ui/ — shadcn/ui-примитивы (@evobgp/ui) с общими стилями globals.css и cn-утилитой; CLI shadcn запускается из apps/web. - apps/web/src/components/reui/ — enterprise-паттерны ReUI. - pnpm workspace (pnpm-workspace.yaml, pnpm-lock.yaml, tsconfig.base.json) заменяет npm-проект в web/. - web/ переименован в web-legacy-svelte/ (архив-референс для миграции); импорты оттуда запрещены правилом WEB-22. - CI (.gitea/workflows/ci.yaml): job web переведён на Node 22 + pnpm 10 (typecheck/lint/build через pnpm --filter @evobgp/web); пути триггеров обновлены под apps/web|packages/ui. - deploy/docker/evobgp-web/Dockerfile: сборка из корня репозитория, pnpm install --frozen-lockfile, выход dist из apps/web/dist. - .cursor/rules/web-shadcn.mdc, context7-stack.mdc, engineering.mdc, AGENTS.md — обновлены под React-стек (WEB-01..WEB-22, DOC-SYNC-06/07). Проверки WEB-19 локально: typecheck, lint, build — exit 0. Co-authored-by: Cursor <[email protected]>
264 lines
10 KiB
TypeScript
264 lines
10 KiB
TypeScript
import { createFileRoute } from '@tanstack/react-router'
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||
import { AlertTriangle, Clock, Info, ListTodo, RefreshCw } from 'lucide-react'
|
||
import { toast } from 'sonner'
|
||
import { useState } from 'react'
|
||
|
||
import { Alert, AlertDescription, AlertTitle } from '@evobgp/ui/components/alert'
|
||
import { Badge } from '@evobgp/ui/components/badge'
|
||
import { Button } from '@evobgp/ui/components/button'
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@evobgp/ui/components/card'
|
||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@evobgp/ui/components/tabs'
|
||
import {
|
||
Table,
|
||
TableBody,
|
||
TableCell,
|
||
TableHead,
|
||
TableHeader,
|
||
TableRow,
|
||
} from '@evobgp/ui/components/table'
|
||
|
||
import { PageHeader } from '@/components/page-header'
|
||
import { QueryState } from '@/components/query-state'
|
||
import { SectionCards, type SectionCardItem } from '@/components/section-cards'
|
||
import { SectionCardsSkeleton } from '@/components/skeletons'
|
||
import { LoadingButton } from '@/components/loading-button'
|
||
|
||
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,
|
||
})
|
||
|
||
function ScheduleComponent() {
|
||
const modulesQ = useQuery(modulesListQueryOptions())
|
||
const jobsQ = useQuery(operationsJobsQueryOptions())
|
||
const qc = useQueryClient()
|
||
const [refreshing, setRefreshing] = useState<Record<string, boolean>>({})
|
||
|
||
const modules = modulesQ.data?.items ?? []
|
||
const jobs = jobsQ.data?.items ?? []
|
||
const loading = modulesQ.isLoading || jobsQ.isLoading
|
||
const running = jobs.filter((j) => j.status === 'running' || j.status === 'queued').length
|
||
const failed = jobs.filter((j) =>
|
||
['failed', 'error', 'cancelled'].includes(j.status.toLowerCase()),
|
||
).length
|
||
|
||
const items: SectionCardItem[] = [
|
||
{ label: 'Всего задач', value: jobs.length, icon: <ListTodo className="size-4" />, hint: 'в выборке' },
|
||
{ label: 'В работе', value: running, icon: <Clock className="size-4" />, hint: 'queued и running' },
|
||
{
|
||
label: 'С ошибкой',
|
||
value: failed,
|
||
icon: <AlertTriangle className="size-4" />,
|
||
hint: failed > 0 ? 'требуют внимания' : 'без ошибок',
|
||
variant: failed > 0 ? 'warning' : 'default',
|
||
},
|
||
]
|
||
|
||
const refreshMutation = useMutation({
|
||
mutationFn: async (id: string) => apiMutate<{ job_id?: string }>(`/v1/modules/${id}/refresh`, 'POST'),
|
||
onMutate: (id) => setRefreshing((s) => ({ ...s, [id]: true })),
|
||
onSuccess: (data, id) => {
|
||
if (data === undefined) toast.message('Обновление не требуется (тип IP_RANGES)')
|
||
else toast.success('Задача поставлена в очередь')
|
||
void qc.invalidateQueries({ queryKey: ['operations'] })
|
||
void qc.invalidateQueries({ queryKey: ['modules'] })
|
||
setRefreshing((s) => ({ ...s, [id]: false }))
|
||
},
|
||
onError: (e, id) => {
|
||
toast.error(e instanceof Error ? e.message : 'Не удалось запустить')
|
||
setRefreshing((s) => ({ ...s, [id]: false }))
|
||
},
|
||
})
|
||
|
||
return (
|
||
<div className="flex flex-col gap-6">
|
||
<PageHeader
|
||
title="Расписание и задачи"
|
||
description="Интервалы обновления модулей и ручной запуск"
|
||
actions={
|
||
<Button
|
||
variant="outline"
|
||
size="sm"
|
||
onClick={() => {
|
||
void modulesQ.refetch()
|
||
void jobsQ.refetch()
|
||
}}
|
||
disabled={loading}
|
||
>
|
||
<RefreshCw className={loading ? 'animate-spin' : ''} />
|
||
Обновить
|
||
</Button>
|
||
}
|
||
/>
|
||
|
||
<Alert className="border-info/30 bg-info/5">
|
||
<Info className="text-info" />
|
||
<AlertTitle>Как работает расписание</AlertTitle>
|
||
<AlertDescription>
|
||
Планировщик использует <code className="text-xs">refresh_interval_sec</code> и опционально{' '}
|
||
<code className="text-xs">cron_expr</code>. Ручной запуск —{' '}
|
||
<code className="text-xs">POST /v1/modules/{id}/refresh</code>.
|
||
</AlertDescription>
|
||
</Alert>
|
||
|
||
{loading ? <SectionCardsSkeleton count={3} /> : <SectionCards items={items} />}
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Модули</CardTitle>
|
||
<CardDescription>Расписание обновления и ручной запуск ingest</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="p-0">
|
||
<QueryState
|
||
data={modules}
|
||
isLoading={modulesQ.isLoading}
|
||
isError={modulesQ.isError}
|
||
error={modulesQ.error}
|
||
empty={modules.length === 0}
|
||
emptyTitle="Нет модулей"
|
||
onRetry={() => modulesQ.refetch()}
|
||
>
|
||
{(items) => (
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>Модуль</TableHead>
|
||
<TableHead>Тип</TableHead>
|
||
<TableHead>Расписание</TableHead>
|
||
<TableHead>Обновлено</TableHead>
|
||
<TableHead>Статус</TableHead>
|
||
<TableHead className="w-32 text-right" />
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{items.map((m) => (
|
||
<TableRow key={m.id}>
|
||
<TableCell className="font-medium">{m.name}</TableCell>
|
||
<TableCell>
|
||
<Badge variant="outline">{m.type}</Badge>
|
||
</TableCell>
|
||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||
{m.cron_expr ?? (m.refresh_interval_sec ? `${m.refresh_interval_sec}s` : '—')}
|
||
</TableCell>
|
||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||
{m.last_refreshed_at ? new Date(m.last_refreshed_at).toLocaleString('ru-RU') : '—'}
|
||
</TableCell>
|
||
<TableCell>
|
||
{m.enabled ? (
|
||
<Badge variant="default">Вкл</Badge>
|
||
) : (
|
||
<Badge variant="secondary">Выкл</Badge>
|
||
)}
|
||
</TableCell>
|
||
<TableCell className="text-right">
|
||
<LoadingButton
|
||
size="sm"
|
||
variant="secondary"
|
||
loading={!!refreshing[m.id]}
|
||
onClick={() => refreshMutation.mutate(m.id)}
|
||
>
|
||
<RefreshCw />
|
||
Обновить
|
||
</LoadingButton>
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
)}
|
||
</QueryState>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<Card>
|
||
<CardHeader>
|
||
<CardTitle className="text-base">Задачи</CardTitle>
|
||
<CardDescription>Последние задачи из API</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="p-0">
|
||
<JobsTabs jobs={jobs} loading={jobsQ.isLoading} />
|
||
</CardContent>
|
||
</Card>
|
||
</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 (
|
||
<Tabs defaultValue="all">
|
||
<TabsList>
|
||
<TabsTrigger value="all">Все ({jobs.length})</TabsTrigger>
|
||
<TabsTrigger value="refresh">Обновление ({refresh.length})</TabsTrigger>
|
||
<TabsTrigger value="failed">С ошибкой ({failed.length})</TabsTrigger>
|
||
</TabsList>
|
||
<TabsContent value="all" className="mt-0">
|
||
<JobsTable items={jobs} loading={loading} />
|
||
</TabsContent>
|
||
<TabsContent value="refresh" className="mt-0">
|
||
<JobsTable items={refresh} loading={loading} />
|
||
</TabsContent>
|
||
<TabsContent value="failed" className="mt-0">
|
||
<JobsTable items={failed} loading={loading} />
|
||
</TabsContent>
|
||
</Tabs>
|
||
)
|
||
}
|
||
|
||
function JobsTable({ items, loading }: { items: JobRow[]; loading: boolean }) {
|
||
if (loading) return <div className="p-6 text-center text-sm text-muted-foreground">Загрузка…</div>
|
||
if (items.length === 0)
|
||
return <div className="p-6 text-center text-sm text-muted-foreground">Нет задач</div>
|
||
return (
|
||
<Table>
|
||
<TableHeader>
|
||
<TableRow>
|
||
<TableHead>Вид</TableHead>
|
||
<TableHead>Статус</TableHead>
|
||
<TableHead>Создана</TableHead>
|
||
<TableHead>Завершена</TableHead>
|
||
<TableHead>Ошибка</TableHead>
|
||
</TableRow>
|
||
</TableHeader>
|
||
<TableBody>
|
||
{items.map((j) => (
|
||
<TableRow key={j.job_id}>
|
||
<TableCell className="font-medium">{j.kind}</TableCell>
|
||
<TableCell>
|
||
<Badge
|
||
variant={
|
||
j.status === 'succeeded'
|
||
? 'default'
|
||
: j.status === 'failed'
|
||
? 'destructive'
|
||
: 'secondary'
|
||
}
|
||
>
|
||
{j.status}
|
||
</Badge>
|
||
</TableCell>
|
||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||
{j.created_at ? new Date(j.created_at).toLocaleString('ru-RU') : '—'}
|
||
</TableCell>
|
||
<TableCell className="whitespace-nowrap text-xs text-muted-foreground">
|
||
{j.finished_at ? new Date(j.finished_at).toLocaleString('ru-RU') : '—'}
|
||
</TableCell>
|
||
<TableCell className="max-w-xs truncate text-xs text-destructive">
|
||
{j.error ?? ''}
|
||
</TableCell>
|
||
</TableRow>
|
||
))}
|
||
</TableBody>
|
||
</Table>
|
||
)
|
||
}
|